diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9ae3eea..80d4847ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,61 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.7b] - Not released + +### Enhancements +- **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: + - `queue:update_own` / `queue:update_all` + - `queue:delete_own` / `queue:delete_all` + - `archives:update_own` / `archives:update_all` + - `archives:delete_own` / `archives:delete_all` + - `archives:reprint_own` / `archives:reprint_all` + - `library:update_own` / `library:update_all` + - `library:delete_own` / `library:delete_all` + - Administrators group gets `*_all` permissions (can modify any items) + - Operators group gets `*_own` permissions (can only modify their own items) + - Ownerless items (legacy data without creator) require `*_all` permission + - Bulk operations skip items user doesn't have permission to modify + - User deletion now offers choice: delete user's items or keep them (become ownerless) + - Backend enforces permissions on all API endpoints (not just frontend UI) + - Automatic migration upgrades existing groups to new permission model +- **User Tracking for Archives, Library & Queue** (Issue #206): + - Track and display who uploaded each archive file + - Track and display who uploaded each library file (File Manager) + - Track and display who added each print job to the queue + - Shows username on archive cards, library files, queue items, and printer cards (while printing) + - Works when authentication is enabled; gracefully hidden when auth is disabled + - Database migration adds `created_by_id` columns to `print_archives`, `library_files`, and `print_queue` tables +- **Separate AMS RFID Permission** (Issue #204): + - Added new `printers:ams_rfid` permission for re-reading AMS RFID tags + - Allows granting RFID re-read access without full printer control permissions + - Operators group includes this permission by default + - Available in Settings > Users > Group Editor as a toggleable permission +- **Schedule Button on Archive Cards** (Issue #208): + - Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling + - Previously only available in the context menu (right-click) + - Respects `queue:create` permission for users with restricted access +- **Streaming Overlay Improvements** (Issue #164): + - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15) + - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background + - Increased default camera FPS from 10 to 15 for smoother video across all camera views +- **Simplified Backup/Restore System**: + - Complete backup now creates a single ZIP file containing the entire database and all data directories + - Includes: database, archives, library files, thumbnails, timelapses, icons, projects, and plate calibration data + - Portable backups: works across different installations and data directories + - Faster backup/restore: direct file copy instead of JSON export/import + - Progress indicator and navigation blocking during backup/restore operations + - Removed ~2000 lines of legacy JSON-based backup/restore code + +### Fixes +- **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 + ## [0.1.6-final] - 2026-01-31 ### New Features diff --git a/README.md b/README.md index 2e5c1a24c..f305c2971 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ### 📊 Monitoring & Control - Real-time printer status via WebSocket - Live camera streaming (MJPEG) & snapshots with multi-viewer support -- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`) +- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`) - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment) - Fan status monitoring (part cooling, auxiliary, chamber) @@ -159,6 +159,7 @@ - Default groups: Administrators, Operators, Viewers - JWT tokens with secure password hashing - User management (create, edit, delete, groups) +- User activity tracking (who uploaded archives, library files, queued prints, started prints) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index b19855f21..5bf950fa6 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -8,10 +8,13 @@ from fastapi.responses import FileResponse, Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission from backend.app.core.config import settings from backend.app.core.database import get_db +from backend.app.core.permissions import Permission from backend.app.models.archive import PrintArchive from backend.app.models.filament import Filament +from backend.app.models.user import User from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest from backend.app.services.archive import ArchiveService @@ -96,6 +99,9 @@ def archive_to_response( "energy_kwh": archive.energy_kwh, "energy_cost": archive.energy_cost, "created_at": archive.created_at, + # User tracking (Issue #206) + "created_by_id": archive.created_by_id, + "created_by_username": archive.created_by.username if archive.created_by else None, } # Add computed time accuracy fields @@ -707,25 +713,42 @@ async def update_archive( archive_id: int, update_data: ArchiveUpdate, db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.ARCHIVES_UPDATE_ALL, + Permission.ARCHIVES_UPDATE_OWN, + ) + ), ): """Update archive metadata (tags, notes, cost, is_favorite, project_id).""" from sqlalchemy.orm import selectinload + user, can_modify_all = auth_result + result = await db.execute( - select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id) + select(PrintArchive) + .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by)) + .where(PrintArchive.id == archive_id) ) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") + # Ownership check + if not can_modify_all: + if archive.created_by_id != user.id: + raise HTTPException(403, "You can only update your own archives") + for field, value in update_data.model_dump(exclude_unset=True).items(): setattr(archive, field, value) await db.commit() - # Re-fetch with project relationship loaded after commit + # Re-fetch with relationships loaded after commit result = await db.execute( - select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id) + select(PrintArchive) + .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by)) + .where(PrintArchive.id == archive_id) ) archive = result.scalar_one_or_none() @@ -928,8 +951,30 @@ async def backfill_content_hashes(db: AsyncSession = Depends(get_db)): @router.delete("/{archive_id}") -async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)): +async def delete_archive( + archive_id: int, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.ARCHIVES_DELETE_ALL, + Permission.ARCHIVES_DELETE_OWN, + ) + ), +): """Delete an archive.""" + user, can_modify_all = auth_result + + # Get archive first to check ownership + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + # Ownership check + if not can_modify_all: + if archive.created_by_id != user.id: + raise HTTPException(403, "You can only delete your own archives") + service = ArchiveService(db) if not await service.delete_archive(archive_id): raise HTTPException(404, "Archive not found") @@ -2018,6 +2063,7 @@ async def upload_archive( file: UploadFile = File(...), printer_id: int | None = None, db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(require_auth_if_enabled), ): """Manually upload a 3MF file to archive.""" if not file.filename or not file.filename.endswith(".3mf"): @@ -2035,6 +2081,7 @@ async def upload_archive( archive = await service.archive_print( printer_id=printer_id, source_file=temp_path, + created_by_id=current_user.id if current_user else None, ) if not archive: @@ -2051,6 +2098,7 @@ async def upload_archives_bulk( files: list[UploadFile] = File(...), printer_id: int | None = None, db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(require_auth_if_enabled), ): """Bulk upload multiple 3MF files to archive.""" results = [] @@ -2072,6 +2120,7 @@ async def upload_archives_bulk( archive = await service.archive_print( printer_id=printer_id, source_file=temp_path, + created_by_id=current_user.id if current_user else None, ) if archive: @@ -2424,6 +2473,12 @@ async def reprint_archive( printer_id: int, body: ReprintRequest | None = None, db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.ARCHIVES_REPRINT_ALL, + Permission.ARCHIVES_REPRINT_OWN, + ) + ), ): """Send an archived 3MF file to a printer and start printing.""" from backend.app.main import register_expected_print @@ -2435,6 +2490,8 @@ async def reprint_archive( ) from backend.app.services.printer_manager import printer_manager + user, can_modify_all = auth_result + # Use defaults if no body provided if body is None: body = ReprintRequest() @@ -2445,6 +2502,11 @@ async def reprint_archive( if not archive: raise HTTPException(404, "Archive not found") + # Ownership check + if not can_modify_all: + if archive.created_by_id != user.id: + raise HTTPException(403, "You can only reprint your own archives") + # Get printer result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -2476,14 +2538,22 @@ async def reprint_archive( # Get FTP retry settings ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings() + logger.info( + f"Reprint FTP upload starting: printer={printer.name} ({printer.model}), " + f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, " + f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}" + ) + # Delete existing file if present (avoids 553 error) - await delete_file_async( + logger.debug(f"Deleting existing file {remote_path} if present...") + delete_result = await delete_file_async( printer.ip_address, printer.access_code, remote_path, socket_timeout=ftp_timeout, printer_model=printer.model, ) + logger.debug(f"Delete result: {delete_result}") if ftp_retry_enabled: uploaded = await with_ftp_retry( @@ -2509,7 +2579,16 @@ async def reprint_archive( ) if not uploaded: - raise HTTPException(500, "Failed to upload file to printer") + logger.error( + f"FTP upload failed for reprint: printer={printer.name}, model={printer.model}, " + f"ip={printer.ip_address}, file={remote_filename}. " + "Check logs above for storage diagnostics and specific error codes." + ) + raise HTTPException( + 500, + "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). " + "See server logs for detailed diagnostics.", + ) # Register this as an expected print so we don't create a duplicate archive register_expected_print(printer_id, remote_filename, archive_id) @@ -2555,6 +2634,11 @@ async def reprint_archive( if not started: raise HTTPException(500, "Failed to start print") + # Track who started this print (Issue #206) + if user: + printer_manager.set_current_print_user(printer_id, user.id, user.username) + logger.info(f"Reprint started by user: {user.username}") + return { "status": "printing", "printer_id": printer_id, diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 0c75b63ef..957e1a396 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -13,13 +13,21 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, Up from fastapi.responses import FileResponse as FastAPIFileResponse from sqlalchemy import func, select 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, +) from backend.app.core.config import settings as app_settings from backend.app.core.database import get_db +from backend.app.core.permissions import Permission from backend.app.models.archive import PrintArchive from backend.app.models.library import LibraryFile, LibraryFolder from backend.app.models.print_queue import PrintQueueItem from backend.app.models.project import Project +from backend.app.models.user import User from backend.app.schemas.library import ( AddToQueueError, AddToQueueRequest, @@ -75,6 +83,30 @@ def get_library_thumbnails_dir() -> Path: return thumbnails_dir +def to_relative_path(absolute_path: Path | str) -> str: + """Convert an absolute path to a path relative to base_dir for storage.""" + if not absolute_path: + return "" + abs_path = Path(absolute_path) + base_dir = Path(app_settings.base_dir) + try: + return str(abs_path.relative_to(base_dir)) + except ValueError: + # Path is not under base_dir, return as-is (shouldn't happen normally) + return str(abs_path) + + +def to_absolute_path(relative_path: str | None) -> Path | None: + """Convert a relative path (from database) to an absolute path for file operations.""" + if not relative_path: + return None + # Handle already-absolute paths (for backwards compatibility during migration) + path = Path(relative_path) + if path.is_absolute(): + return path + return Path(app_settings.base_dir) / relative_path + + def calculate_file_hash(file_path: Path) -> str: """Calculate SHA256 hash of a file.""" sha256_hash = hashlib.sha256() @@ -500,8 +532,16 @@ async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = D @router.delete("/folders/{folder_id}") -async def delete_folder(folder_id: int, db: AsyncSession = Depends(get_db)): - """Delete a folder and all its contents (cascade).""" +async def delete_folder( + folder_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)), +): + """Delete a folder and all its contents (cascade). + + Note: Folders require library:delete_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() @@ -563,7 +603,7 @@ async def list_files( include_root: If True and folder_id is None, returns files at root level. If False and folder_id is None, returns all files. """ - query = select(LibraryFile) + query = select(LibraryFile).options(selectinload(LibraryFile.created_by)) if folder_id is not None: query = query.where(LibraryFile.folder_id == folder_id) @@ -610,6 +650,8 @@ async def list_files( thumbnail_path=f.thumbnail_path, print_count=f.print_count, duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0, + created_by_id=f.created_by_id, + created_by_username=f.created_by.username if f.created_by else None, created_at=f.created_at, print_name=print_name, print_time_seconds=print_time, @@ -627,6 +669,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), ): """Upload a file to the library.""" try: @@ -722,16 +765,17 @@ async def upload_file( if generate_stl_thumbnails: thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) - # Create database entry + # Create database entry (store relative paths for portability) library_file = LibraryFile( folder_id=folder_id, filename=filename, - file_path=str(file_path), + file_path=to_relative_path(file_path), file_type=file_type, file_size=len(content), file_hash=file_hash, - thumbnail_path=thumbnail_path, + thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None, file_metadata=metadata if metadata else None, + created_by_id=current_user.id if current_user else None, ) db.add(library_file) await db.flush() @@ -761,6 +805,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), ): """Upload and extract a ZIP file to the library. @@ -958,16 +1003,17 @@ async def extract_zip_file( if generate_stl_thumbnails: thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) - # Create database entry + # Create database entry (store relative paths for portability) library_file = LibraryFile( folder_id=target_folder_id, filename=filename, - file_path=str(file_path), + file_path=to_relative_path(file_path), file_type=file_type, file_size=len(file_content), file_hash=file_hash, - thumbnail_path=thumbnail_path, + thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None, file_metadata=metadata if metadata else None, + created_by_id=current_user.id if current_user else None, ) db.add(library_file) await db.flush() @@ -1062,9 +1108,9 @@ async def batch_generate_stl_thumbnails( failed = 0 for stl_file in stl_files: - file_path = Path(stl_file.file_path) + file_path = to_absolute_path(stl_file.file_path) - if not file_path.exists(): + if not file_path or not file_path.exists(): results.append( BatchThumbnailResult( file_id=stl_file.id, @@ -1080,8 +1126,8 @@ async def batch_generate_stl_thumbnails( thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) if thumbnail_path: - # Update database - stl_file.thumbnail_path = thumbnail_path + # Update database with relative path + stl_file.thumbnail_path = to_relative_path(thumbnail_path) await db.flush() results.append( BatchThumbnailResult( @@ -1633,14 +1679,22 @@ async def print_library_file( # Get FTP retry settings ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings() + logger.info( + f"Library print FTP upload starting: printer={printer.name} ({printer.model}), " + f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, " + f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}" + ) + # Delete existing file if present (avoids 553 error) - await delete_file_async( + logger.debug(f"Deleting existing file {remote_path} if present...") + delete_result = await delete_file_async( printer.ip_address, printer.access_code, remote_path, socket_timeout=ftp_timeout, printer_model=printer.model, ) + logger.debug(f"Delete result: {delete_result}") # Upload file to printer if ftp_retry_enabled: @@ -1667,7 +1721,16 @@ async def print_library_file( ) if not uploaded: - raise HTTPException(status_code=500, detail="Failed to upload file to printer") + logger.error( + f"FTP upload failed for library print: printer={printer.name}, model={printer.model}, " + f"ip={printer.ip_address}, file={remote_filename}. " + "Check logs above for storage diagnostics and specific error codes." + ) + raise HTTPException( + status_code=500, + detail="Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). " + "See server logs for detailed diagnostics.", + ) # Register this as an expected print so we don't create a duplicate archive register_expected_print(printer_id, remote_filename, archive.id) @@ -1725,7 +1788,9 @@ 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)): """Get a file by ID with full details.""" - result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + result = await db.execute( + select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id) + ) file = result.scalar_one_or_none() if not file: @@ -1782,20 +1847,39 @@ async def get_file(file_id: int, db: AsyncSession = Depends(get_db)): notes=file.notes, duplicates=duplicates if duplicates else None, duplicate_count=duplicate_count, + created_by_id=file.created_by_id, + created_by_username=file.created_by.username if file.created_by else None, created_at=file.created_at, updated_at=file.updated_at, ) @router.put("/files/{file_id}", response_model=FileResponseSchema) -async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends(get_db)): +async def update_file( + file_id: int, + data: FileUpdate, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.LIBRARY_UPDATE_ALL, + Permission.LIBRARY_UPDATE_OWN, + ) + ), +): """Update a file's metadata.""" + user, can_modify_all = auth_result + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) file = result.scalar_one_or_none() if not file: raise HTTPException(status_code=404, detail="File not found") + # Ownership check + if not can_modify_all: + if file.created_by_id != user.id: + raise HTTPException(status_code=403, detail="You can only update your own files") + if data.filename is not None: # Validate filename doesn't contain path separators if "/" in data.filename or "\\" in data.filename: @@ -1833,20 +1917,38 @@ async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends @router.delete("/files/{file_id}") -async def delete_file(file_id: int, db: AsyncSession = Depends(get_db)): +async def delete_file( + file_id: int, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.LIBRARY_DELETE_ALL, + Permission.LIBRARY_DELETE_OWN, + ) + ), +): """Delete a file.""" + user, can_modify_all = auth_result + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) file = result.scalar_one_or_none() if not file: raise HTTPException(status_code=404, detail="File not found") + # Ownership check + if not can_modify_all: + if file.created_by_id != user.id: + raise HTTPException(status_code=403, detail="You can only delete your own files") + # Delete actual files try: - if file.file_path and os.path.exists(file.file_path): - os.remove(file.file_path) - if file.thumbnail_path and os.path.exists(file.thumbnail_path): - os.remove(file.thumbnail_path) + abs_file_path = to_absolute_path(file.file_path) + abs_thumb_path = to_absolute_path(file.thumbnail_path) + if abs_file_path and abs_file_path.exists(): + abs_file_path.unlink() + if abs_thumb_path and abs_thumb_path.exists(): + abs_thumb_path.unlink() except Exception as e: logger.warning(f"Failed to delete file from disk: {e}") @@ -1867,11 +1969,12 @@ async def download_file(file_id: int, db: AsyncSession = Depends(get_db)): if not file: raise HTTPException(status_code=404, detail="File not found") - if not file.file_path or not os.path.exists(file.file_path): + abs_path = to_absolute_path(file.file_path) + if not abs_path or not abs_path.exists(): raise HTTPException(status_code=404, detail="File not found on disk") return FastAPIFileResponse( - file.file_path, + str(abs_path), filename=file.filename, media_type="application/octet-stream", ) @@ -1886,11 +1989,12 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): if not file: raise HTTPException(status_code=404, detail="File not found") - if not file.thumbnail_path or not os.path.exists(file.thumbnail_path): + abs_thumb_path = to_absolute_path(file.thumbnail_path) + if not abs_thumb_path or not abs_thumb_path.exists(): raise HTTPException(status_code=404, detail="Thumbnail not found") # Detect media type from extension - thumb_ext = os.path.splitext(file.thumbnail_path)[1].lower() + thumb_ext = abs_thumb_path.suffix.lower() media_types = { ".png": "image/png", ".jpg": "image/jpeg", @@ -1900,7 +2004,7 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): } media_type = media_types.get(thumb_ext, "image/png") - return FastAPIFileResponse(file.thumbnail_path, media_type=media_type) + return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type) @router.get("/files/{file_id}/gcode") @@ -1912,17 +2016,18 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)): if not file: raise HTTPException(status_code=404, detail="File not found") - if not file.file_path or not os.path.exists(file.file_path): + abs_path = to_absolute_path(file.file_path) + if not abs_path or not abs_path.exists(): raise HTTPException(status_code=404, detail="File not found on disk") if file.file_type == "gcode": - return FastAPIFileResponse(file.file_path, media_type="text/plain") + return FastAPIFileResponse(str(abs_path), media_type="text/plain") elif file.file_type == "3mf": # Extract gcode from 3mf import zipfile try: - with zipfile.ZipFile(file.file_path, "r") as zf: + with zipfile.ZipFile(str(abs_path), "r") as zf: # Find gcode file gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")] if not gcode_files: @@ -1962,28 +2067,54 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)): @router.post("/bulk-delete", response_model=BulkDeleteResponse) -async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db)): - """Delete multiple files and/or folders.""" +async def bulk_delete( + data: BulkDeleteRequest, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.LIBRARY_DELETE_ALL, + Permission.LIBRARY_DELETE_OWN, + ) + ), +): + """Delete multiple files and/or folders. + + Files not owned by the user are skipped (unless user has *_all permission). + """ + user, can_modify_all = auth_result deleted_files = 0 deleted_folders = 0 + skipped_files = 0 # Delete files first 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_files += 1 + continue + try: - if file.file_path and os.path.exists(file.file_path): - os.remove(file.file_path) - if file.thumbnail_path and os.path.exists(file.thumbnail_path): - os.remove(file.thumbnail_path) + abs_file_path = to_absolute_path(file.file_path) + abs_thumb_path = to_absolute_path(file.thumbnail_path) + if abs_file_path and abs_file_path.exists(): + abs_file_path.unlink() + if abs_thumb_path and abs_thumb_path.exists(): + abs_thumb_path.unlink() except Exception as e: logger.warning(f"Failed to delete file from disk: {e}") await db.delete(file) deleted_files += 1 # Delete folders (cascade will handle contents) + # Note: Folders don't have ownership tracking currently, require *_all permission for folder_id in data.folder_ids: + if not can_modify_all: + # Users without *_all permission cannot delete folders + continue + result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id)) folder = result.scalar_one_or_none() if folder: diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 4e1ad7b65..d4109a5db 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -12,12 +12,15 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission from backend.app.core.config import settings from backend.app.core.database import get_db +from backend.app.core.permissions import Permission from backend.app.models.archive import PrintArchive from backend.app.models.library import LibraryFile from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer +from backend.app.models.user import User from backend.app.schemas.print_queue import ( PrintQueueBulkUpdate, PrintQueueBulkUpdateResponse, @@ -140,6 +143,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "completed_at": item.completed_at, "error_message": item.error_message, "created_at": item.created_at, + # User tracking (Issue #206) + "created_by_id": item.created_by_id, + "created_by_username": item.created_by.username if item.created_by else None, } response = PrintQueueItemResponse(**item_dict) if item.archive: @@ -174,6 +180,7 @@ async def list_queue( selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer), selectinload(PrintQueueItem.library_file), + selectinload(PrintQueueItem.created_by), ) .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position) ) @@ -196,6 +203,7 @@ async def list_queue( async def add_to_queue( data: PrintQueueItemCreate, db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(require_auth_if_enabled), ): """Add an item to the print queue.""" # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E") @@ -298,13 +306,14 @@ async def add_to_queue( use_ams=data.use_ams, position=max_pos + 1, status="pending", + created_by_id=current_user.id if current_user else None, ) db.add(item) await db.commit() await db.refresh(item) # Load relationships for response - await db.refresh(item, ["archive", "printer", "library_file"]) + await db.refresh(item, ["archive", "printer", "library_file", "created_by"]) source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}" target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned") @@ -353,11 +362,20 @@ async def add_to_queue( async def bulk_update_queue_items( data: PrintQueueBulkUpdate, db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.QUEUE_UPDATE_ALL, + Permission.QUEUE_UPDATE_OWN, + ) + ), ): """Bulk update multiple queue items with the same values. Only pending items can be updated. Non-pending items are skipped. + Items not owned by the user are also skipped (unless user has *_all permission). """ + user, can_modify_all = auth_result + if not data.item_ids: raise HTTPException(400, "No item IDs provided") @@ -384,6 +402,11 @@ async def bulk_update_queue_items( skipped_count += 1 continue + # Ownership check + if not can_modify_all and item.created_by_id != user.id: + skipped_count += 1 + continue + for field, value in update_data.items(): setattr(item, field, value) updated_count += 1 @@ -394,7 +417,8 @@ async def bulk_update_queue_items( return PrintQueueBulkUpdateResponse( updated_count=updated_count, skipped_count=skipped_count, - message=f"Updated {updated_count} items" + (f", skipped {skipped_count} non-pending" if skipped_count else ""), + message=f"Updated {updated_count} items" + + (f", skipped {skipped_count} non-pending/not-owned" if skipped_count else ""), ) @@ -407,6 +431,7 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)): selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer), selectinload(PrintQueueItem.library_file), + selectinload(PrintQueueItem.created_by), ) .where(PrintQueueItem.id == item_id) ) @@ -421,13 +446,26 @@ async def update_queue_item( item_id: int, data: PrintQueueItemUpdate, db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.QUEUE_UPDATE_ALL, + Permission.QUEUE_UPDATE_OWN, + ) + ), ): """Update a queue item.""" + user, can_modify_all = auth_result + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") + # Ownership check + if not can_modify_all: + if item.created_by_id != user.id: + raise HTTPException(403, "You can only update your own queue items") + if item.status != "pending": raise HTTPException(400, "Can only update pending items") @@ -469,20 +507,36 @@ async def update_queue_item( setattr(item, field, value) await db.commit() - await db.refresh(item, ["archive", "printer", "library_file"]) + await db.refresh(item, ["archive", "printer", "library_file", "created_by"]) logger.info(f"Updated queue item {item_id}") return _enrich_response(item) @router.delete("/{item_id}") -async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)): +async def delete_queue_item( + item_id: int, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.QUEUE_DELETE_ALL, + Permission.QUEUE_DELETE_OWN, + ) + ), +): """Remove an item from the queue.""" + user, can_modify_all = auth_result + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") + # Ownership check + if not can_modify_all: + if item.created_by_id != user.id: + raise HTTPException(403, "You can only delete your own queue items") + if item.status == "printing": raise HTTPException(400, "Cannot delete item that is currently printing") @@ -511,13 +565,29 @@ async def reorder_queue( @router.post("/{item_id}/cancel") -async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)): +async def cancel_queue_item( + item_id: int, + db: AsyncSession = Depends(get_db), + auth_result: tuple[User | None, bool] = Depends( + require_ownership_permission( + Permission.QUEUE_UPDATE_ALL, + Permission.QUEUE_UPDATE_OWN, + ) + ), +): """Cancel a pending queue item.""" + user, can_modify_all = auth_result + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") + # Ownership check + if not can_modify_all: + if item.created_by_id != user.id: + raise HTTPException(403, "You can only cancel your own queue items") + if item.status not in ("pending",): raise HTTPException(400, f"Cannot cancel item with status '{item.status}'") @@ -624,7 +694,7 @@ async def start_queue_item( # Clear manual_start flag so scheduler picks it up item.manual_start = False await db.commit() - await db.refresh(item, ["archive", "printer", "library_file"]) + await db.refresh(item, ["archive", "printer", "library_file", "created_by"]) logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)") return _enrich_response(item) diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index bc4c128b8..3f924859f 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -175,7 +175,10 @@ async def delete_printer( printer_manager.disconnect_printer(printer_id) - if not delete_archives: + if delete_archives: + # Delete all archives for this printer + await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id)) + else: # Orphan the archives instead of deleting them from sqlalchemy import update @@ -444,6 +447,27 @@ async def get_printer_status( ) +@router.get("/{printer_id}/current-print-user") +async def get_current_print_user( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): + """Get the user who started the current print (for reprint tracking). + + Returns user info if available, empty object otherwise. + This tracks users for reprints (which bypass the queue). + For queue-based prints, use the queue item's created_by field instead. + """ + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + user_info = printer_manager.get_current_print_user(printer_id) + return user_info or {} + + @router.post("/{printer_id}/refresh-status") async def refresh_printer_status( printer_id: int, @@ -1668,7 +1692,7 @@ async def refresh_ams_slot( printer_id: int, ams_id: int, slot_id: int, - _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID), db: AsyncSession = Depends(get_db), ): """Re-read RFID for an AMS slot (triggers filament info refresh).""" diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 5b0a3dd45..4f746e684 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -1,36 +1,17 @@ import io -import json import zipfile from datetime import datetime from pathlib import Path -from fastapi import APIRouter, Depends, File, Query, UploadFile +from fastapi import APIRouter, Depends, File, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.config import settings as app_settings from backend.app.core.database import get_db -from backend.app.models.api_key import APIKey -from backend.app.models.archive import PrintArchive -from backend.app.models.external_link import ExternalLink -from backend.app.models.filament import Filament -from backend.app.models.github_backup import GitHubBackupConfig -from backend.app.models.group import Group -from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance -from backend.app.models.notification import NotificationProvider -from backend.app.models.notification_template import NotificationTemplate -from backend.app.models.pending_upload import PendingUpload -from backend.app.models.print_queue import PrintQueueItem -from backend.app.models.printer import Printer -from backend.app.models.project import Project -from backend.app.models.project_bom import ProjectBOMItem from backend.app.models.settings import Settings -from backend.app.models.smart_plug import SmartPlug -from backend.app.models.user import User from backend.app.schemas.settings import AppSettings, AppSettingsUpdate -from backend.app.services.printer_manager import printer_manager -from backend.app.services.spoolman import init_spoolman_client router = APIRouter(prefix="/settings", tags=["settings"]) @@ -238,2057 +219,133 @@ async def update_spoolman_settings( @router.get("/backup") -async def export_backup( - db: AsyncSession = Depends(get_db), - include_settings: bool = Query(True, description="Include app settings"), - include_notifications: bool = Query(True, description="Include notification providers"), - include_templates: bool = Query(True, description="Include notification templates"), - include_smart_plugs: bool = Query(True, description="Include smart plugs"), - include_external_links: bool = Query(True, description="Include external sidebar links"), - include_printers: bool = Query(False, description="Include printers (without access codes)"), - include_plate_calibration: bool = Query(False, description="Include plate detection reference images"), - include_filaments: bool = Query(False, description="Include filament inventory"), - include_maintenance: bool = Query( - False, description="Include maintenance types, per-printer settings, and history" - ), - include_print_queue: bool = Query(False, description="Include print queue items"), - include_archives: bool = Query(False, description="Include print archive metadata"), - include_projects: bool = Query(False, description="Include projects with BOM items"), - include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"), - include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"), - include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"), - include_users: bool = Query( - False, description="Include users (passwords not exported - users will need new passwords)" - ), - include_groups: bool = Query(False, description="Include groups and user-group assignments"), - include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"), -): - """Export selected data as JSON backup.""" - backup: dict = { - "version": "2.0", - "exported_at": datetime.utcnow().isoformat(), - "included": [], - } +async def create_backup(db: AsyncSession = Depends(get_db)): + """Create a complete backup (database + all files) as a ZIP. - # Settings - if include_settings: - result = await db.execute(select(Settings)) - db_settings = result.scalars().all() - backup["settings"] = {s.key: s.value for s in db_settings} - backup["included"].append("settings") + This is a simplified backup that includes the entire SQLite database + and all data directories. It is complete by definition and cannot miss data. + """ + import shutil + import tempfile - # Notification providers - if include_notifications: - # Build printer ID to serial lookup for cross-system backup - printer_id_to_serial: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number + from sqlalchemy import text - result = await db.execute(select(NotificationProvider)) - providers = result.scalars().all() - backup["notification_providers"] = [] - for p in providers: - # Use printer_serial for cross-system compatibility - provider_printer_id = getattr(p, "printer_id", None) - printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None + from backend.app.core.database import engine - backup["notification_providers"].append( - { - "name": p.name, - "provider_type": p.provider_type, - "enabled": p.enabled, - "config": json.loads(p.config) if isinstance(p.config, str) else p.config, - "on_print_start": p.on_print_start, - "on_print_complete": p.on_print_complete, - "on_print_failed": p.on_print_failed, - "on_print_stopped": p.on_print_stopped, - "on_print_progress": p.on_print_progress, - "on_printer_offline": p.on_printer_offline, - "on_printer_error": p.on_printer_error, - "on_filament_low": p.on_filament_low, - "on_maintenance_due": p.on_maintenance_due, - "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False), - "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False), - "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False), - "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False), - "on_plate_not_empty": getattr(p, "on_plate_not_empty", True), - "on_queue_job_added": getattr(p, "on_queue_job_added", False), - "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False), - "on_queue_job_started": getattr(p, "on_queue_job_started", False), - "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True), - "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True), - "on_queue_job_failed": getattr(p, "on_queue_job_failed", True), - "on_queue_completed": getattr(p, "on_queue_completed", False), - "quiet_hours_enabled": p.quiet_hours_enabled, - "quiet_hours_start": p.quiet_hours_start, - "quiet_hours_end": p.quiet_hours_end, - "daily_digest_enabled": getattr(p, "daily_digest_enabled", False), - "daily_digest_time": getattr(p, "daily_digest_time", None), - "printer_serial": printer_serial, - } - ) - backup["included"].append("notification_providers") - - # Notification templates - if include_templates: - result = await db.execute(select(NotificationTemplate)) - templates = result.scalars().all() - backup["notification_templates"] = [] - for t in templates: - backup["notification_templates"].append( - { - "event_type": t.event_type, - "name": t.name, - "title_template": t.title_template, - "body_template": t.body_template, - "is_default": t.is_default, - } - ) - backup["included"].append("notification_templates") - - # Smart plugs - if include_smart_plugs: - result = await db.execute(select(SmartPlug)) - plugs = result.scalars().all() - backup["smart_plugs"] = [] - - # Build printer ID to serial mapping - printer_id_to_serial: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - - for plug in plugs: - backup["smart_plugs"].append( - { - "name": plug.name, - "plug_type": plug.plug_type, - "ip_address": plug.ip_address, - "ha_entity_id": plug.ha_entity_id, - "ha_power_entity": plug.ha_power_entity, - "ha_energy_today_entity": plug.ha_energy_today_entity, - "ha_energy_total_entity": plug.ha_energy_total_entity, - # MQTT plug fields (legacy) - "mqtt_topic": plug.mqtt_topic, - "mqtt_multiplier": plug.mqtt_multiplier, - # MQTT power fields - "mqtt_power_topic": plug.mqtt_power_topic, - "mqtt_power_path": plug.mqtt_power_path, - "mqtt_power_multiplier": plug.mqtt_power_multiplier, - # MQTT energy fields - "mqtt_energy_topic": plug.mqtt_energy_topic, - "mqtt_energy_path": plug.mqtt_energy_path, - "mqtt_energy_multiplier": plug.mqtt_energy_multiplier, - # MQTT state fields - "mqtt_state_topic": plug.mqtt_state_topic, - "mqtt_state_path": plug.mqtt_state_path, - "mqtt_state_on_value": plug.mqtt_state_on_value, - "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, - "enabled": plug.enabled, - "auto_on": plug.auto_on, - "auto_off": plug.auto_off, - "off_delay_mode": plug.off_delay_mode, - "off_delay_minutes": plug.off_delay_minutes, - "off_temp_threshold": plug.off_temp_threshold, - "username": plug.username, - "password": plug.password, - "power_alert_enabled": plug.power_alert_enabled, - "power_alert_high": plug.power_alert_high, - "power_alert_low": plug.power_alert_low, - "schedule_enabled": plug.schedule_enabled, - "schedule_on_time": plug.schedule_on_time, - "schedule_off_time": plug.schedule_off_time, - "show_in_switchbar": plug.show_in_switchbar, - "show_on_printer_card": plug.show_on_printer_card, - } - ) - backup["included"].append("smart_plugs") - - # External links - if include_external_links: - result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order)) - links = result.scalars().all() - backup["external_links"] = [] - icons_dir = app_settings.base_dir / "icons" - for link in links: - link_data = { - "name": link.name, - "url": link.url, - "icon": link.icon, - "sort_order": link.sort_order, - } - # Include custom icon file path if exists - if link.custom_icon: - link_data["custom_icon"] = link.custom_icon - icon_path = icons_dir / link.custom_icon - if icon_path.exists(): - link_data["custom_icon_path"] = f"icons/{link.custom_icon}" - backup["external_links"].append(link_data) - backup["included"].append("external_links") - - # Printers (access codes only included if explicitly requested) - if include_printers: - result = await db.execute(select(Printer)) - printers = result.scalars().all() - backup["printers"] = [] - for printer in printers: - printer_data = { - "name": printer.name, - "serial_number": printer.serial_number, - "ip_address": printer.ip_address, - "model": printer.model, - "location": printer.location, - "nozzle_count": printer.nozzle_count, - "is_active": printer.is_active, - "auto_archive": printer.auto_archive, - "print_hours_offset": printer.print_hours_offset, - "runtime_seconds": printer.runtime_seconds, - "external_camera_url": printer.external_camera_url, - "external_camera_type": printer.external_camera_type, - "external_camera_enabled": printer.external_camera_enabled, - "plate_detection_enabled": printer.plate_detection_enabled, - "plate_detection_roi_x": printer.plate_detection_roi_x, - "plate_detection_roi_y": printer.plate_detection_roi_y, - "plate_detection_roi_w": printer.plate_detection_roi_w, - "plate_detection_roi_h": printer.plate_detection_roi_h, - } - if include_access_codes: - printer_data["access_code"] = printer.access_code - backup["printers"].append(printer_data) - backup["included"].append("printers") - if include_access_codes: - backup["included"].append("access_codes") - - # Plate calibration references (requires include_printers) - if include_printers and include_plate_calibration: - plate_cal_dir = app_settings.plate_calibration_dir - if plate_cal_dir.exists(): - backup["plate_calibration"] = { - "files": [], - "printer_id_to_serial": {}, # Map old printer IDs to serial numbers for restore - } - for cal_file in plate_cal_dir.iterdir(): - if cal_file.is_file(): - backup["plate_calibration"]["files"].append(cal_file.name) - # Extract printer ID from filename (e.g., "printer_1_ref_0.jpg" -> 1) - if cal_file.name.startswith("printer_"): - parts = cal_file.name.split("_") - if len(parts) >= 2 and parts[1].isdigit(): - old_printer_id = int(parts[1]) - if old_printer_id not in backup["plate_calibration"]["printer_id_to_serial"]: - # Look up serial number for this printer ID - backup["plate_calibration"]["printer_id_to_serial"][old_printer_id] = ( - printer_id_to_serial.get(old_printer_id) - ) - if backup["plate_calibration"]["files"]: - backup["included"].append("plate_calibration") - - # Filaments - if include_filaments: - result = await db.execute(select(Filament)) - filaments = result.scalars().all() - backup["filaments"] = [] - for f in filaments: - backup["filaments"].append( - { - "name": f.name, - "type": f.type, - "brand": f.brand, - "color": f.color, - "color_hex": f.color_hex, - "cost_per_kg": f.cost_per_kg, - "spool_weight_g": f.spool_weight_g, - "currency": f.currency, - "density": f.density, - "print_temp_min": f.print_temp_min, - "print_temp_max": f.print_temp_max, - "bed_temp_min": f.bed_temp_min, - "bed_temp_max": f.bed_temp_max, - } - ) - backup["included"].append("filaments") - - # Maintenance types and records - if include_maintenance: - # Maintenance types - result = await db.execute(select(MaintenanceType)) - types = result.scalars().all() - backup["maintenance_types"] = [] - for mt in types: - backup["maintenance_types"].append( - { - "name": mt.name, - "description": mt.description, - "default_interval_hours": mt.default_interval_hours, - "interval_type": mt.interval_type, - "icon": mt.icon, - "is_system": mt.is_system, - } - ) - backup["included"].append("maintenance_types") - - # Printer maintenance settings (per-printer custom intervals, enabled status, last performed) - result = await db.execute(select(PrinterMaintenance)) - printer_maint = result.scalars().all() - backup["printer_maintenance"] = [] - - # Build lookups for printer serial and maintenance type name - printer_id_to_serial: dict[int, str] = {} - maint_type_id_to_name: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - for mt in types: - maint_type_id_to_name[mt.id] = mt.name - - for pm in printer_maint: - backup["printer_maintenance"].append( - { - "printer_serial": printer_id_to_serial.get(pm.printer_id), - "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id), - "custom_interval_hours": pm.custom_interval_hours, - "custom_interval_type": pm.custom_interval_type, - "enabled": pm.enabled, - "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None, - "last_performed_hours": pm.last_performed_hours, - } - ) - backup["included"].append("printer_maintenance") - - # Maintenance history - result = await db.execute(select(MaintenanceHistory)) - history = result.scalars().all() - backup["maintenance_history"] = [] - - # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping - pm_id_to_info: dict[int, tuple[str | None, str | None]] = {} - for pm in printer_maint: - pm_id_to_info[pm.id] = ( - printer_id_to_serial.get(pm.printer_id), - maint_type_id_to_name.get(pm.maintenance_type_id), - ) - - for mh in history: - info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None)) - backup["maintenance_history"].append( - { - "printer_serial": info[0], - "maintenance_type_name": info[1], - "performed_at": mh.performed_at.isoformat() if mh.performed_at else None, - "hours_at_maintenance": mh.hours_at_maintenance, - "notes": mh.notes, - } - ) - backup["included"].append("maintenance_history") - - # Print queue - if include_print_queue: - result = await db.execute(select(PrintQueueItem)) - queue_items = result.scalars().all() - backup["print_queue"] = [] - - # Build lookups - printer_id_to_serial: dict[int, str] = {} - archive_id_to_hash: dict[int, str | None] = {} - project_id_to_name: dict[int, str] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - ar_result = await db.execute(select(PrintArchive)) - for ar in ar_result.scalars().all(): - archive_id_to_hash[ar.id] = ar.content_hash - proj_result = await db.execute(select(Project)) - for proj in proj_result.scalars().all(): - project_id_to_name[proj.id] = proj.name - - for qi in queue_items: - backup["print_queue"].append( - { - "printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None, - "archive_hash": archive_id_to_hash.get(qi.archive_id), - "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None, - "position": qi.position, - "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None, - "require_previous_success": qi.require_previous_success, - "auto_off_after": qi.auto_off_after, - "manual_start": qi.manual_start, - "ams_mapping": qi.ams_mapping, - "plate_id": qi.plate_id, - "bed_levelling": qi.bed_levelling, - "flow_cali": qi.flow_cali, - "vibration_cali": qi.vibration_cali, - "layer_inspect": qi.layer_inspect, - "timelapse": qi.timelapse, - "use_ams": qi.use_ams, - "status": qi.status, - "started_at": qi.started_at.isoformat() if qi.started_at else None, - "completed_at": qi.completed_at.isoformat() if qi.completed_at else None, - "error_message": qi.error_message, - } - ) - backup["included"].append("print_queue") - - # Collect files for ZIP (icons + archives + project attachments) - backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path) base_dir = app_settings.base_dir + db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", "")) - # Add external link icon files - if include_external_links and "external_links" in backup: - icons_dir = base_dir / "icons" - for link_data in backup["external_links"]: - if "custom_icon_path" in link_data: - icon_path = icons_dir / link_data["custom_icon"] - if icon_path.exists(): - backup_files.append((link_data["custom_icon_path"], icon_path)) + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) - # Add plate calibration reference images - if "plate_calibration" in backup: - plate_cal_dir = app_settings.plate_calibration_dir - plate_cal_data = backup["plate_calibration"] - # Support both old list format and new dict format - filenames = plate_cal_data.get("files", []) if isinstance(plate_cal_data, dict) else plate_cal_data - for filename in filenames: - file_path = plate_cal_dir / filename - if file_path.exists(): - backup_files.append((f"plate_calibration/{filename}", file_path)) + # 1. Checkpoint WAL to ensure all data is in main db file + async with engine.begin() as conn: + await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")) - # Print archives with file paths for ZIP - if include_archives: - result = await db.execute(select(PrintArchive)) - archives = result.scalars().all() - backup["archives"] = [] + # 2. Copy database file + shutil.copy2(db_path, temp_path / "bambuddy.db") - # Build project ID to name mapping for archive export - project_id_to_name: dict[int, str] = {} - if include_projects: - proj_result = await db.execute(select(Project)) - for proj in proj_result.scalars().all(): - project_id_to_name[proj.id] = proj.name + # 3. Copy data directories (if they exist) + dirs_to_backup = [ + ("archive", base_dir / "archive"), + ("virtual_printer", base_dir / "virtual_printer"), + ("plate_calibration", app_settings.plate_calibration_dir), + ("icons", base_dir / "icons"), + ("projects", base_dir / "projects"), + ] - # Build printer ID to serial mapping for archive export - printer_id_to_serial: dict[int, str] = {} - if include_printers: - printer_result = await db.execute(select(Printer)) - for pr in printer_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number + for name, src_dir in dirs_to_backup: + if src_dir.exists() and any(src_dir.iterdir()): + shutil.copytree(src_dir, temp_path / name) - for a in archives: - archive_data = { - "filename": a.filename, - "project_name": project_id_to_name.get(a.project_id) if a.project_id else None, - "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None, - "file_size": a.file_size, - "content_hash": a.content_hash, - "print_name": a.print_name, - "print_time_seconds": a.print_time_seconds, - "filament_used_grams": a.filament_used_grams, - "filament_type": a.filament_type, - "filament_color": a.filament_color, - "layer_height": a.layer_height, - "total_layers": a.total_layers, - "nozzle_diameter": a.nozzle_diameter, - "bed_temperature": a.bed_temperature, - "nozzle_temperature": a.nozzle_temperature, - "status": a.status, - "started_at": a.started_at.isoformat() if a.started_at else None, - "completed_at": a.completed_at.isoformat() if a.completed_at else None, - "makerworld_url": a.makerworld_url, - "designer": a.designer, - "external_url": a.external_url, - "is_favorite": a.is_favorite, - "tags": a.tags, - "notes": a.notes, - "cost": a.cost, - "failure_reason": a.failure_reason, - "quantity": a.quantity, - "energy_kwh": a.energy_kwh, - "energy_cost": a.energy_cost, - "extra_data": a.extra_data, - "photos": a.photos, - } - - # Collect file paths for ZIP - if a.file_path: - file_path = base_dir / a.file_path - if file_path.exists(): - archive_data["file_path"] = a.file_path - backup_files.append((a.file_path, file_path)) - - if a.thumbnail_path: - thumb_path = base_dir / a.thumbnail_path - if thumb_path.exists(): - archive_data["thumbnail_path"] = a.thumbnail_path - backup_files.append((a.thumbnail_path, thumb_path)) - - if a.timelapse_path: - timelapse_path = base_dir / a.timelapse_path - if timelapse_path.exists(): - archive_data["timelapse_path"] = a.timelapse_path - backup_files.append((a.timelapse_path, timelapse_path)) - - if a.source_3mf_path: - source_path = base_dir / a.source_3mf_path - if source_path.exists(): - archive_data["source_3mf_path"] = a.source_3mf_path - backup_files.append((a.source_3mf_path, source_path)) - - if a.f3d_path: - f3d_path = base_dir / a.f3d_path - if f3d_path.exists(): - archive_data["f3d_path"] = a.f3d_path - backup_files.append((a.f3d_path, f3d_path)) - - # Include photos - if a.photos: - for photo in a.photos: - photo_path = base_dir / "archive" / "photos" / photo - if photo_path.exists(): - zip_photo_path = f"archive/photos/{photo}" - backup_files.append((zip_photo_path, photo_path)) - - backup["archives"].append(archive_data) - backup["included"].append("archives") - - # Projects with BOM items - if include_projects: - result = await db.execute(select(Project)) - projects = result.scalars().all() - backup["projects"] = [] - - for p in projects: - # Get BOM items for this project - bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id)) - bom_items = bom_result.scalars().all() - - project_data = { - "name": p.name, - "description": p.description, - "color": p.color, - "status": p.status, - "target_count": p.target_count, - "notes": p.notes, - "tags": p.tags, - "due_date": p.due_date.isoformat() if p.due_date else None, - "priority": p.priority, - "budget": p.budget, - "is_template": p.is_template, - "template_source_id": p.template_source_id, - "parent_id": p.parent_id, - "bom_items": [ - { - "name": item.name, - "quantity_needed": item.quantity_needed, - "quantity_acquired": item.quantity_acquired, - "unit_price": item.unit_price, - "sourcing_url": item.sourcing_url, - "stl_filename": item.stl_filename, - "remarks": item.remarks, - "sort_order": item.sort_order, - } - for item in bom_items - ], - } - - # Include attachment files for ZIP - if p.attachments: - project_data["attachments"] = p.attachments - attachments_dir = base_dir / "projects" / str(p.id) / "attachments" - for att in p.attachments: - att_path = attachments_dir / att.get("filename", "") - if att_path.exists(): - zip_path = f"projects/{p.id}/attachments/{att['filename']}" - backup_files.append((zip_path, att_path)) - - backup["projects"].append(project_data) - backup["included"].append("projects") - - # Pending uploads (virtual printer queue mode) - if include_pending_uploads: - result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending")) - pending_uploads = result.scalars().all() - backup["pending_uploads"] = [] - - for p in pending_uploads: - upload_data = { - "filename": p.filename, - "file_size": p.file_size, - "source_ip": p.source_ip, - "status": p.status, - "tags": p.tags, - "notes": p.notes, - "project_id": p.project_id, - "uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None, - } - - # Include the actual file if it exists - if p.file_path: - file_path = Path(p.file_path) - if file_path.exists(): - # Store relative path for ZIP - rel_path = f"pending_uploads/{p.filename}" - upload_data["file_path"] = rel_path - backup_files.append((rel_path, file_path)) - - backup["pending_uploads"].append(upload_data) - backup["included"].append("pending_uploads") - - # API keys (note: key_hash cannot be restored, new keys must be generated) - if include_api_keys: - # Build printer ID to serial mapping for cross-system compatibility - printer_id_to_serial: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - - result = await db.execute(select(APIKey)) - api_keys = result.scalars().all() - backup["api_keys"] = [] - for key in api_keys: - # Convert printer_ids from list of IDs to list of serials - printer_serials = None - if key.printer_ids: - printer_serials = [ - printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial - ] - - backup["api_keys"].append( - { - "name": key.name, - "key_prefix": key.key_prefix, # For identification only - "can_queue": key.can_queue, - "can_control_printer": key.can_control_printer, - "can_read_status": key.can_read_status, - "printer_serials": printer_serials, # Use serials instead of IDs - "enabled": key.enabled, - "expires_at": key.expires_at.isoformat() if key.expires_at else None, - } - ) - backup["included"].append("api_keys") - - # Users (note: passwords not exported for security - users will need new passwords on import) - if include_users: - result = await db.execute(select(User)) - users = result.scalars().all() - backup["users"] = [] - for user in users: - backup["users"].append( - { - "username": user.username, - "role": user.role, - "is_active": user.is_active, - "groups": [g.name for g in user.groups], - # password_hash intentionally not exported for security - } - ) - backup["included"].append("users") - - # Groups (permission groups) - if include_groups: - result = await db.execute(select(Group)) - groups = result.scalars().all() - backup["groups"] = [] - for group in groups: - backup["groups"].append( - { - "name": group.name, - "description": group.description, - "permissions": group.permissions, - "is_system": group.is_system, - } - ) - backup["included"].append("groups") - - # GitHub backup configuration - if include_github_backup: - result = await db.execute(select(GitHubBackupConfig).limit(1)) - config = result.scalar_one_or_none() - if config: - backup["github_backup"] = { - "repository_url": config.repository_url, - # access_token intentionally not exported for security - "branch": config.branch, - "schedule_enabled": config.schedule_enabled, - "schedule_type": config.schedule_type, - "backup_kprofiles": config.backup_kprofiles, - "backup_cloud_profiles": config.backup_cloud_profiles, - "backup_settings": config.backup_settings, - "enabled": config.enabled, - } - backup["included"].append("github_backup") - - # If there are files to include (icons or archives), create ZIP file - if backup_files: + # 4. Create ZIP zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: - # Add backup.json - zf.writestr("backup.json", json.dumps(backup, indent=2)) - - # Add all backup files (icons, archives, etc.) - added_files = set() - for zip_path, local_path in backup_files: - if zip_path not in added_files and local_path.exists(): - try: - zf.write(local_path, zip_path) - added_files.add(zip_path) - except Exception: - pass # Skip files that can't be read + for file_path in temp_path.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(temp_path) + zf.write(file_path, arcname) zip_buffer.seek(0) filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip" + return StreamingResponse( zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) - # Otherwise return JSON - return JSONResponse( - content=backup, - headers={ - "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json" - }, - ) - @router.post("/restore") -async def import_backup( +async def restore_backup( file: UploadFile = File(...), - overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"), db: AsyncSession = Depends(get_db), ): - """Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing.""" - try: + """Restore from a complete backup ZIP. + + This is a simplified restore that replaces the database and all data directories + from the backup ZIP. Requires a restart after restore. + """ + import shutil + import tempfile + + from fastapi import HTTPException + + from backend.app.core.database import close_all_connections + + base_dir = app_settings.base_dir + db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", "")) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # 1. Read and extract ZIP content = await file.read() - base_dir = app_settings.base_dir - files_restored = 0 - # Store plate calibration files for later (need printer ID remapping after printers restored) - plate_cal_files: dict[str, bytes] = {} - # Check if it's a ZIP file - if file.filename and file.filename.endswith(".zip"): - try: - zip_buffer = io.BytesIO(content) - with zipfile.ZipFile(zip_buffer, "r") as zf: - # Extract backup.json - if "backup.json" not in zf.namelist(): - return {"success": False, "message": "Invalid ZIP: missing backup.json"} + # Check if it's a valid ZIP + if not file.filename or not file.filename.endswith(".zip"): + raise HTTPException(400, "Invalid backup file: must be a .zip file") - backup_content = zf.read("backup.json") - backup = json.loads(backup_content.decode("utf-8")) - - # Extract all other files to base_dir - for zip_path in zf.namelist(): - if zip_path == "backup.json": - continue - # Ensure path is safe (no path traversal) - if ".." in zip_path or zip_path.startswith("/"): - continue - # Plate calibration files - store for later processing after printers are restored - if zip_path.startswith("plate_calibration/"): - filename = zip_path.replace("plate_calibration/", "", 1) - if filename: # Skip directory entries - plate_cal_files[filename] = zf.read(zip_path) - continue - target_path = base_dir / zip_path - target_path.parent.mkdir(parents=True, exist_ok=True) - with zf.open(zip_path) as src, open(target_path, "wb") as dst: - dst.write(src.read()) - files_restored += 1 - except zipfile.BadZipFile: - return {"success": False, "message": "Invalid ZIP file"} - else: - backup = json.loads(content.decode("utf-8")) - except json.JSONDecodeError as e: - return {"success": False, "message": f"Invalid JSON: {str(e)}"} - except Exception as e: - return {"success": False, "message": f"Invalid backup file: {str(e)}"} - - restored = { - "settings": 0, - "notification_providers": 0, - "notification_templates": 0, - "smart_plugs": 0, - "external_links": 0, - "printers": 0, - "filaments": 0, - "maintenance_types": 0, - "projects": 0, - "pending_uploads": 0, - "users": 0, - "groups": 0, - "github_backup": 0, - } - skipped = { - "settings": 0, - "notification_providers": 0, - "notification_templates": 0, - "smart_plugs": 0, - "external_links": 0, - "printers": 0, - "filaments": 0, - "maintenance_types": 0, - "archives": 0, - "projects": 0, - "pending_uploads": 0, - "users": 0, - "groups": 0, - "github_backup": 0, - } - skipped_details = { - "notification_providers": [], - "smart_plugs": [], - "external_links": [], - "printers": [], - "filaments": [], - "maintenance_types": [], - "archives": [], - "projects": [], - "pending_uploads": [], - "users": [], - "groups": [], - } - - # Restore settings (always overwrites) - if "settings" in backup: - for key, value in backup["settings"].items(): - # Convert value to proper string format for storage - if isinstance(value, bool): - str_value = "true" if value else "false" - elif value is None: - str_value = "None" - else: - str_value = str(value) - await set_setting(db, key, str_value) - restored["settings"] += 1 - # Flush settings to ensure they're persisted before continuing - await db.flush() - - # Restore printers FIRST (skip or overwrite duplicates by serial_number) - # Nearly everything in the app references printers, so they must be imported first - if "printers" in backup: - for printer_data in backup["printers"]: - result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.name = printer_data["name"] - existing.ip_address = printer_data["ip_address"] - existing.model = printer_data.get("model") - existing.location = printer_data.get("location") - existing.nozzle_count = printer_data.get("nozzle_count", 1) - existing.auto_archive = printer_data.get("auto_archive", True) - existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0) - existing.runtime_seconds = printer_data.get("runtime_seconds", 0) - - # If backup includes access_code, also update access_code and is_active - backup_access_code = printer_data.get("access_code") - if backup_access_code and backup_access_code != "CHANGE_ME": - existing.access_code = backup_access_code - is_active_val = printer_data.get("is_active", False) - if isinstance(is_active_val, str): - is_active_val = is_active_val.lower() == "true" - existing.is_active = is_active_val - - # Restore external camera settings - existing.external_camera_url = printer_data.get("external_camera_url") - existing.external_camera_type = printer_data.get("external_camera_type") - existing.external_camera_enabled = printer_data.get("external_camera_enabled", False) - - # Restore plate detection settings - existing.plate_detection_enabled = printer_data.get("plate_detection_enabled", False) - existing.plate_detection_roi_x = printer_data.get("plate_detection_roi_x") - existing.plate_detection_roi_y = printer_data.get("plate_detection_roi_y") - existing.plate_detection_roi_w = printer_data.get("plate_detection_roi_w") - existing.plate_detection_roi_h = printer_data.get("plate_detection_roi_h") - - restored["printers"] += 1 - else: - skipped["printers"] += 1 - skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})") - else: - # Use access code from backup if provided, otherwise require manual setup - access_code = printer_data.get("access_code") - has_access_code = access_code and access_code != "CHANGE_ME" - is_active_from_backup = printer_data.get("is_active", False) - # Handle bool or string "true"/"false" - if isinstance(is_active_from_backup, str): - is_active_from_backup = is_active_from_backup.lower() == "true" - - printer = Printer( - name=printer_data["name"], - serial_number=printer_data["serial_number"], - ip_address=printer_data["ip_address"], - access_code=access_code if has_access_code else "CHANGE_ME", - model=printer_data.get("model"), - location=printer_data.get("location"), - nozzle_count=printer_data.get("nozzle_count", 1), - is_active=is_active_from_backup if has_access_code else False, - auto_archive=printer_data.get("auto_archive", True), - print_hours_offset=printer_data.get("print_hours_offset", 0.0), - runtime_seconds=printer_data.get("runtime_seconds", 0), - external_camera_url=printer_data.get("external_camera_url"), - external_camera_type=printer_data.get("external_camera_type"), - external_camera_enabled=printer_data.get("external_camera_enabled", False), - plate_detection_enabled=printer_data.get("plate_detection_enabled", False), - plate_detection_roi_x=printer_data.get("plate_detection_roi_x"), - plate_detection_roi_y=printer_data.get("plate_detection_roi_y"), - plate_detection_roi_w=printer_data.get("plate_detection_roi_w"), - plate_detection_roi_h=printer_data.get("plate_detection_roi_h"), - ) - db.add(printer) - restored["printers"] += 1 - # Flush printers so other sections can look them up - await db.flush() - - # Restore plate calibration files (remap printer IDs based on serial numbers) - if plate_cal_files: - # Build serial_number -> new_printer_id mapping - serial_to_new_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - serial_to_new_id[pr.serial_number] = pr.id - - # Get old_id -> serial mapping from backup (supports both old list format and new dict format) - plate_cal_data = backup.get("plate_calibration", {}) - if isinstance(plate_cal_data, dict): - old_id_to_serial: dict[int, str | None] = { - int(k): v for k, v in plate_cal_data.get("printer_id_to_serial", {}).items() - } - else: - old_id_to_serial = {} - - # Build old_id -> new_id mapping - old_id_to_new_id: dict[int, int] = {} - for old_id, serial in old_id_to_serial.items(): - if serial and serial in serial_to_new_id: - old_id_to_new_id[old_id] = serial_to_new_id[serial] - - app_settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True) - - for filename, file_data in plate_cal_files.items(): - # Parse old printer ID from filename (e.g., "printer_3_ref_0.jpg" -> 3) - new_filename = filename - if filename.startswith("printer_"): - parts = filename.split("_") - if len(parts) >= 2 and parts[1].isdigit(): - old_printer_id = int(parts[1]) - if old_printer_id in old_id_to_new_id: - new_printer_id = old_id_to_new_id[old_printer_id] - # Replace old ID with new ID in filename - new_filename = filename.replace(f"printer_{old_printer_id}_", f"printer_{new_printer_id}_", 1) - - target_path = app_settings.plate_calibration_dir / new_filename - with open(target_path, "wb") as f: - f.write(file_data) - files_restored += 1 - - # Restore notification providers (skip or overwrite duplicates by name) - # Build printer serial to ID lookup (printers were restored first) - if "notification_providers" in backup: - printer_serial_to_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - for provider_data in backup["notification_providers"]: - # Look up printer_id from serial (supports both old printer_id and new printer_serial format) - printer_serial = provider_data.get("printer_serial") - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id") - - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.name == provider_data["name"]) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update existing provider - existing.provider_type = provider_data["provider_type"] - existing.enabled = provider_data.get("enabled", True) - existing.config = json.dumps(provider_data.get("config", {})) - existing.on_print_start = provider_data.get("on_print_start", False) - existing.on_print_complete = provider_data.get("on_print_complete", True) - existing.on_print_failed = provider_data.get("on_print_failed", True) - existing.on_print_stopped = provider_data.get("on_print_stopped", True) - existing.on_print_progress = provider_data.get("on_print_progress", False) - existing.on_printer_offline = provider_data.get("on_printer_offline", False) - existing.on_printer_error = provider_data.get("on_printer_error", False) - existing.on_filament_low = provider_data.get("on_filament_low", False) - existing.on_maintenance_due = provider_data.get("on_maintenance_due", False) - existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False) - existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False) - existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False) - existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False) - existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True) - existing.on_queue_job_added = provider_data.get("on_queue_job_added", False) - existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False) - existing.on_queue_job_started = provider_data.get("on_queue_job_started", False) - existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True) - existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True) - existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True) - existing.on_queue_completed = provider_data.get("on_queue_completed", False) - existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False) - existing.quiet_hours_start = provider_data.get("quiet_hours_start") - existing.quiet_hours_end = provider_data.get("quiet_hours_end") - existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False) - existing.daily_digest_time = provider_data.get("daily_digest_time") - existing.printer_id = printer_id - restored["notification_providers"] += 1 - else: - skipped["notification_providers"] += 1 - skipped_details["notification_providers"].append(provider_data["name"]) - else: - provider = NotificationProvider( - name=provider_data["name"], - provider_type=provider_data["provider_type"], - enabled=provider_data.get("enabled", True), - config=json.dumps(provider_data.get("config", {})), - on_print_start=provider_data.get("on_print_start", False), - on_print_complete=provider_data.get("on_print_complete", True), - on_print_failed=provider_data.get("on_print_failed", True), - on_print_stopped=provider_data.get("on_print_stopped", True), - on_print_progress=provider_data.get("on_print_progress", False), - on_printer_offline=provider_data.get("on_printer_offline", False), - on_printer_error=provider_data.get("on_printer_error", False), - on_filament_low=provider_data.get("on_filament_low", False), - on_maintenance_due=provider_data.get("on_maintenance_due", False), - on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False), - on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False), - on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False), - on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False), - on_plate_not_empty=provider_data.get("on_plate_not_empty", True), - on_queue_job_added=provider_data.get("on_queue_job_added", False), - on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False), - on_queue_job_started=provider_data.get("on_queue_job_started", False), - on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True), - on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True), - on_queue_job_failed=provider_data.get("on_queue_job_failed", True), - on_queue_completed=provider_data.get("on_queue_completed", False), - quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False), - quiet_hours_start=provider_data.get("quiet_hours_start"), - quiet_hours_end=provider_data.get("quiet_hours_end"), - daily_digest_enabled=provider_data.get("daily_digest_enabled", False), - daily_digest_time=provider_data.get("daily_digest_time"), - printer_id=printer_id, - ) - db.add(provider) - restored["notification_providers"] += 1 - - # Restore notification templates (update existing by event_type) - if "notification_templates" in backup: - for template_data in backup["notification_templates"]: - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"]) - ) - existing = result.scalar_one_or_none() - if existing: - # Update existing template - existing.name = template_data.get("name", existing.name) - existing.title_template = template_data.get("title_template", existing.title_template) - existing.body_template = template_data.get("body_template", existing.body_template) - existing.is_default = template_data.get("is_default", False) - else: - template = NotificationTemplate( - event_type=template_data["event_type"], - name=template_data["name"], - title_template=template_data["title_template"], - body_template=template_data["body_template"], - is_default=template_data.get("is_default", False), - ) - db.add(template) - restored["notification_templates"] += 1 - - # Restore smart plugs (skip or overwrite duplicates by IP) - # Note: Smart plugs reference printers, so printers should be restored first - if "smart_plugs" in backup: - # Build printer serial to ID lookup - printer_serial_to_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - for plug_data in backup["smart_plugs"]: - # Look up printer_id from serial (supports both old printer_id and new printer_serial format) - printer_serial = plug_data.get("printer_serial") - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id") - - # Determine plug type (default to tasmota for backwards compatibility) - plug_type = plug_data.get("plug_type", "tasmota") - - # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT) - existing = None - plug_identifier = None - if plug_type == "homeassistant" and plug_data.get("ha_entity_id"): - result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"])) - existing = result.scalar_one_or_none() - plug_identifier = plug_data["ha_entity_id"] - elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")): - # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy) - power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic") - result = await db.execute( - select(SmartPlug).where( - (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic) - ) - ) - existing = result.scalar_one_or_none() - plug_identifier = power_topic - elif plug_data.get("ip_address"): - result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) - existing = result.scalar_one_or_none() - plug_identifier = plug_data["ip_address"] - else: - # Skip invalid plug data - continue - - if existing: - if overwrite: - existing.name = plug_data["name"] - existing.plug_type = plug_type - existing.ha_entity_id = plug_data.get("ha_entity_id") - existing.ha_power_entity = plug_data.get("ha_power_entity") - existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity") - existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity") - # MQTT fields (legacy) - existing.mqtt_topic = plug_data.get("mqtt_topic") - existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0) - # MQTT power fields - existing.mqtt_power_topic = plug_data.get("mqtt_power_topic") - existing.mqtt_power_path = plug_data.get("mqtt_power_path") - existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0) - # MQTT energy fields - existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic") - existing.mqtt_energy_path = plug_data.get("mqtt_energy_path") - existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0) - # MQTT state fields - existing.mqtt_state_topic = plug_data.get("mqtt_state_topic") - existing.mqtt_state_path = plug_data.get("mqtt_state_path") - existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value") - existing.printer_id = printer_id - existing.enabled = plug_data.get("enabled", True) - existing.auto_on = plug_data.get("auto_on", True) - existing.auto_off = plug_data.get("auto_off", True) - existing.off_delay_mode = plug_data.get("off_delay_mode", "time") - existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5) - existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70) - existing.username = plug_data.get("username") - existing.password = plug_data.get("password") - existing.power_alert_enabled = plug_data.get("power_alert_enabled", False) - existing.power_alert_high = plug_data.get("power_alert_high") - existing.power_alert_low = plug_data.get("power_alert_low") - existing.schedule_enabled = plug_data.get("schedule_enabled", False) - existing.schedule_on_time = plug_data.get("schedule_on_time") - existing.schedule_off_time = plug_data.get("schedule_off_time") - existing.show_in_switchbar = plug_data.get("show_in_switchbar", False) - existing.show_on_printer_card = plug_data.get("show_on_printer_card", True) - restored["smart_plugs"] += 1 - else: - skipped["smart_plugs"] += 1 - skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_identifier})") - else: - plug = SmartPlug( - name=plug_data["name"], - plug_type=plug_type, - ip_address=plug_data.get("ip_address"), - ha_entity_id=plug_data.get("ha_entity_id"), - ha_power_entity=plug_data.get("ha_power_entity"), - ha_energy_today_entity=plug_data.get("ha_energy_today_entity"), - ha_energy_total_entity=plug_data.get("ha_energy_total_entity"), - # MQTT fields (legacy) - mqtt_topic=plug_data.get("mqtt_topic"), - mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0), - # MQTT power fields - mqtt_power_topic=plug_data.get("mqtt_power_topic"), - mqtt_power_path=plug_data.get("mqtt_power_path"), - mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0), - # MQTT energy fields - mqtt_energy_topic=plug_data.get("mqtt_energy_topic"), - mqtt_energy_path=plug_data.get("mqtt_energy_path"), - mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0), - # MQTT state fields - mqtt_state_topic=plug_data.get("mqtt_state_topic"), - mqtt_state_path=plug_data.get("mqtt_state_path"), - mqtt_state_on_value=plug_data.get("mqtt_state_on_value"), - printer_id=printer_id, - enabled=plug_data.get("enabled", True), - auto_on=plug_data.get("auto_on", True), - auto_off=plug_data.get("auto_off", True), - off_delay_mode=plug_data.get("off_delay_mode", "time"), - off_delay_minutes=plug_data.get("off_delay_minutes", 5), - off_temp_threshold=plug_data.get("off_temp_threshold", 70), - username=plug_data.get("username"), - password=plug_data.get("password"), - power_alert_enabled=plug_data.get("power_alert_enabled", False), - power_alert_high=plug_data.get("power_alert_high"), - power_alert_low=plug_data.get("power_alert_low"), - schedule_enabled=plug_data.get("schedule_enabled", False), - schedule_on_time=plug_data.get("schedule_on_time"), - schedule_off_time=plug_data.get("schedule_off_time"), - show_in_switchbar=plug_data.get("show_in_switchbar", False), - show_on_printer_card=plug_data.get("show_on_printer_card", True), - ) - db.add(plug) - restored["smart_plugs"] += 1 - - # Restore external links (skip or overwrite duplicates by name+url) - if "external_links" in backup: - icons_dir = base_dir / "icons" - icons_dir.mkdir(parents=True, exist_ok=True) - - for link_data in backup["external_links"]: - result = await db.execute( - select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"]) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.icon = link_data.get("icon", "link") - existing.sort_order = link_data.get("sort_order", 0) - # Handle custom icon - if link_data.get("custom_icon"): - existing.custom_icon = link_data["custom_icon"] - restored["external_links"] += 1 - else: - skipped["external_links"] += 1 - skipped_details["external_links"].append(link_data["name"]) - else: - link = ExternalLink( - name=link_data["name"], - url=link_data["url"], - icon=link_data.get("icon", "link"), - custom_icon=link_data.get("custom_icon"), - sort_order=link_data.get("sort_order", 0), - ) - db.add(link) - restored["external_links"] += 1 - - # Restore filaments (skip or overwrite duplicates by name+type+brand) - if "filaments" in backup: - for filament_data in backup["filaments"]: - result = await db.execute( - select(Filament).where( - Filament.name == filament_data["name"], - Filament.type == filament_data["type"], - Filament.brand == filament_data.get("brand"), - ) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.color = filament_data.get("color") - existing.color_hex = filament_data.get("color_hex") - existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0) - existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0) - existing.currency = filament_data.get("currency", "USD") - existing.density = filament_data.get("density") - existing.print_temp_min = filament_data.get("print_temp_min") - existing.print_temp_max = filament_data.get("print_temp_max") - existing.bed_temp_min = filament_data.get("bed_temp_min") - existing.bed_temp_max = filament_data.get("bed_temp_max") - restored["filaments"] += 1 - else: - skipped["filaments"] += 1 - skipped_details["filaments"].append( - f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})" - ) - else: - filament = Filament( - name=filament_data["name"], - type=filament_data["type"], - brand=filament_data.get("brand"), - color=filament_data.get("color"), - color_hex=filament_data.get("color_hex"), - cost_per_kg=filament_data.get("cost_per_kg", 25.0), - spool_weight_g=filament_data.get("spool_weight_g", 1000.0), - currency=filament_data.get("currency", "USD"), - density=filament_data.get("density"), - print_temp_min=filament_data.get("print_temp_min"), - print_temp_max=filament_data.get("print_temp_max"), - bed_temp_min=filament_data.get("bed_temp_min"), - bed_temp_max=filament_data.get("bed_temp_max"), - ) - db.add(filament) - restored["filaments"] += 1 - - # Restore maintenance types (skip or overwrite duplicates by name) - if "maintenance_types" in backup: - for mt_data in backup["maintenance_types"]: - result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.description = mt_data.get("description") - existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0) - existing.interval_type = mt_data.get("interval_type", "hours") - existing.icon = mt_data.get("icon") - # Don't overwrite is_system - restored["maintenance_types"] += 1 - else: - skipped["maintenance_types"] += 1 - skipped_details["maintenance_types"].append(mt_data["name"]) - else: - mt = MaintenanceType( - name=mt_data["name"], - description=mt_data.get("description"), - default_interval_hours=mt_data.get("default_interval_hours", 100.0), - interval_type=mt_data.get("interval_type", "hours"), - icon=mt_data.get("icon"), - is_system=mt_data.get("is_system", False), - ) - db.add(mt) - restored["maintenance_types"] += 1 - - # Restore printer maintenance settings (per-printer) - if "printer_maintenance" in backup: - # Build lookups - printer_serial_to_id: dict[str, int] = {} - maint_type_name_to_id: dict[str, int] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - mt_result = await db.execute(select(MaintenanceType)) - for mt in mt_result.scalars().all(): - maint_type_name_to_id[mt.name] = mt.id - - restored["printer_maintenance"] = 0 - skipped["printer_maintenance"] = 0 - skipped_details["printer_maintenance"] = [] - - for pm_data in backup["printer_maintenance"]: - printer_serial = pm_data.get("printer_serial") - maint_type_name = pm_data.get("maintenance_type_name") - - if not printer_serial or not maint_type_name: - continue - - printer_id = printer_serial_to_id.get(printer_serial) - maint_type_id = maint_type_name_to_id.get(maint_type_name) - - if not printer_id or not maint_type_id: - skipped["printer_maintenance"] += 1 - skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}") - continue - - # Check if exists - result = await db.execute( - select(PrinterMaintenance).where( - PrinterMaintenance.printer_id == printer_id, - PrinterMaintenance.maintenance_type_id == maint_type_id, - ) - ) - existing = result.scalar_one_or_none() - - if existing: - if overwrite: - existing.custom_interval_hours = pm_data.get("custom_interval_hours") - existing.custom_interval_type = pm_data.get("custom_interval_type") - existing.enabled = pm_data.get("enabled", True) - existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0) - if pm_data.get("last_performed_at"): - existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"]) - restored["printer_maintenance"] += 1 - else: - skipped["printer_maintenance"] += 1 - skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}") - else: - pm = PrinterMaintenance( - printer_id=printer_id, - maintenance_type_id=maint_type_id, - custom_interval_hours=pm_data.get("custom_interval_hours"), - custom_interval_type=pm_data.get("custom_interval_type"), - enabled=pm_data.get("enabled", True), - last_performed_hours=pm_data.get("last_performed_hours", 0.0), - ) - if pm_data.get("last_performed_at"): - pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"]) - db.add(pm) - restored["printer_maintenance"] += 1 - - # Restore maintenance history - if "maintenance_history" in backup: - # Build lookups - printer_serial_to_id: dict[str, int] = {} - maint_type_name_to_id: dict[str, int] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - mt_result = await db.execute(select(MaintenanceType)) - for mt in mt_result.scalars().all(): - maint_type_name_to_id[mt.name] = mt.id - - restored["maintenance_history"] = 0 - skipped["maintenance_history"] = 0 - skipped_details["maintenance_history"] = [] - - for mh_data in backup["maintenance_history"]: - printer_serial = mh_data.get("printer_serial") - maint_type_name = mh_data.get("maintenance_type_name") - - if not printer_serial or not maint_type_name: - continue - - printer_id = printer_serial_to_id.get(printer_serial) - maint_type_id = maint_type_name_to_id.get(maint_type_name) - - if not printer_id or not maint_type_id: - skipped["maintenance_history"] += 1 - continue - - # Find the PrinterMaintenance record - result = await db.execute( - select(PrinterMaintenance).where( - PrinterMaintenance.printer_id == printer_id, - PrinterMaintenance.maintenance_type_id == maint_type_id, - ) - ) - pm = result.scalar_one_or_none() - - if not pm: - skipped["maintenance_history"] += 1 - continue - - # Create history entry (no duplicate check - history is append-only) - mh = MaintenanceHistory( - printer_maintenance_id=pm.id, - hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0), - notes=mh_data.get("notes"), - ) - if mh_data.get("performed_at"): - mh.performed_at = datetime.fromisoformat(mh_data["performed_at"]) - db.add(mh) - restored["maintenance_history"] += 1 - - # Restore archives (skip duplicates by content_hash - overwrite not supported for archives) - if "archives" in backup: - # Build printer serial to ID mapping - printer_serial_to_id: dict[str, int] = {} - printer_result = await db.execute(select(Printer)) - for pr in printer_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - for archive_data in backup["archives"]: - # Skip if no content_hash or already exists - content_hash = archive_data.get("content_hash") - if content_hash: - result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash)) - existing = result.scalar_one_or_none() - if existing: - skipped["archives"] += 1 - skipped_details["archives"].append(archive_data.get("filename", "Unknown")) - continue - - # Only restore if file exists (from ZIP extraction) - file_path = archive_data.get("file_path") - if file_path and (base_dir / file_path).exists(): - # Look up printer_id from serial - printer_serial = archive_data.get("printer_serial") - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None - - archive = PrintArchive( - filename=archive_data["filename"], - file_path=file_path, - file_size=archive_data.get("file_size", 0), - content_hash=content_hash, - printer_id=printer_id, - thumbnail_path=archive_data.get("thumbnail_path"), - timelapse_path=archive_data.get("timelapse_path"), - source_3mf_path=archive_data.get("source_3mf_path"), - f3d_path=archive_data.get("f3d_path"), - print_name=archive_data.get("print_name"), - print_time_seconds=archive_data.get("print_time_seconds"), - filament_used_grams=archive_data.get("filament_used_grams"), - filament_type=archive_data.get("filament_type"), - filament_color=archive_data.get("filament_color"), - layer_height=archive_data.get("layer_height"), - total_layers=archive_data.get("total_layers"), - nozzle_diameter=archive_data.get("nozzle_diameter"), - bed_temperature=archive_data.get("bed_temperature"), - nozzle_temperature=archive_data.get("nozzle_temperature"), - status=archive_data.get("status", "completed"), - makerworld_url=archive_data.get("makerworld_url"), - designer=archive_data.get("designer"), - external_url=archive_data.get("external_url"), - is_favorite=archive_data.get("is_favorite", False), - tags=archive_data.get("tags"), - notes=archive_data.get("notes"), - cost=archive_data.get("cost"), - failure_reason=archive_data.get("failure_reason"), - quantity=archive_data.get("quantity", 1), - energy_kwh=archive_data.get("energy_kwh"), - energy_cost=archive_data.get("energy_cost"), - extra_data=archive_data.get("extra_data"), - photos=archive_data.get("photos"), - ) - db.add(archive) - restored["archives"] = restored.get("archives", 0) + 1 - - # Restore projects (skip or overwrite duplicates by name) - if "projects" in backup: - for project_data in backup["projects"]: - result = await db.execute(select(Project).where(Project.name == project_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update existing project - existing.description = project_data.get("description") - existing.color = project_data.get("color") - existing.status = project_data.get("status", "active") - existing.target_count = project_data.get("target_count") - existing.notes = project_data.get("notes") - existing.tags = project_data.get("tags") - existing.priority = project_data.get("priority", "normal") - existing.budget = project_data.get("budget") - existing.is_template = project_data.get("is_template", False) - existing.template_source_id = project_data.get("template_source_id") - existing.parent_id = project_data.get("parent_id") - existing.attachments = project_data.get("attachments") - if project_data.get("due_date"): - existing.due_date = datetime.fromisoformat(project_data["due_date"]) - - # Delete existing BOM items and re-add - await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id)) - for bom_data in project_data.get("bom_items", []): - bom_item = ProjectBOMItem( - project_id=existing.id, - name=bom_data["name"], - quantity_needed=bom_data.get("quantity_needed", 1), - quantity_acquired=bom_data.get("quantity_acquired", 0), - unit_price=bom_data.get("unit_price"), - sourcing_url=bom_data.get("sourcing_url"), - stl_filename=bom_data.get("stl_filename"), - remarks=bom_data.get("remarks"), - sort_order=bom_data.get("sort_order", 0), - ) - db.add(bom_item) - - restored["projects"] += 1 - else: - skipped["projects"] += 1 - skipped_details["projects"].append(project_data["name"]) - else: - # Create new project - project = Project( - name=project_data["name"], - description=project_data.get("description"), - color=project_data.get("color"), - status=project_data.get("status", "active"), - target_count=project_data.get("target_count"), - notes=project_data.get("notes"), - tags=project_data.get("tags"), - priority=project_data.get("priority", "normal"), - budget=project_data.get("budget"), - is_template=project_data.get("is_template", False), - template_source_id=project_data.get("template_source_id"), - parent_id=project_data.get("parent_id"), - attachments=project_data.get("attachments"), - ) - if project_data.get("due_date"): - project.due_date = datetime.fromisoformat(project_data["due_date"]) - - db.add(project) - await db.flush() # Get the project ID - - # Add BOM items - for bom_data in project_data.get("bom_items", []): - bom_item = ProjectBOMItem( - project_id=project.id, - name=bom_data["name"], - quantity_needed=bom_data.get("quantity_needed", 1), - quantity_acquired=bom_data.get("quantity_acquired", 0), - unit_price=bom_data.get("unit_price"), - sourcing_url=bom_data.get("sourcing_url"), - stl_filename=bom_data.get("stl_filename"), - remarks=bom_data.get("remarks"), - sort_order=bom_data.get("sort_order", 0), - ) - db.add(bom_item) - - restored["projects"] += 1 - - # Link archives to projects by name (after both are restored) - if "archives" in backup and "projects" in backup: - # Build project name to ID mapping - proj_result = await db.execute(select(Project)) - project_name_to_id: dict[str, int] = {} - for proj in proj_result.scalars().all(): - project_name_to_id[proj.name] = proj.id - - # Update archives with project_id - for archive_data in backup["archives"]: - project_name = archive_data.get("project_name") - if project_name and project_name in project_name_to_id: - content_hash = archive_data.get("content_hash") - if content_hash: - result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash)) - archive = result.scalar_one_or_none() - if archive: - archive.project_id = project_name_to_id[project_name] - - # Restore print queue (must be after archives and projects) - if "print_queue" in backup: - # Build lookups - printer_serial_to_id: dict[str, int] = {} - archive_hash_to_id: dict[str, int] = {} - project_name_to_id: dict[str, int] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - ar_result = await db.execute(select(PrintArchive)) - for ar in ar_result.scalars().all(): - if ar.content_hash: - archive_hash_to_id[ar.content_hash] = ar.id - - proj_result = await db.execute(select(Project)) - for proj in proj_result.scalars().all(): - project_name_to_id[proj.name] = proj.id - - restored["print_queue"] = 0 - skipped["print_queue"] = 0 - skipped_details["print_queue"] = [] - - for qi_data in backup["print_queue"]: - printer_serial = qi_data.get("printer_serial") # Can be None for unassigned items - archive_hash = qi_data.get("archive_hash") - - # Archive is required, but printer can be None (unassigned) - if not archive_hash: - skipped["print_queue"] += 1 - continue - - # Look up printer_id (None if unassigned or printer not found) - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None - archive_id = archive_hash_to_id.get(archive_hash) - - # Archive must exist, but printer is optional (unassigned items) - if not archive_id: - skipped["print_queue"] += 1 - skipped_details["print_queue"].append( - f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}" - ) - continue - - # If printer_serial was specified but printer not found, skip - if printer_serial and not printer_id: - skipped["print_queue"] += 1 - skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}") - continue - - project_name = qi_data.get("project_name") - project_id = project_name_to_id.get(project_name) if project_name else None - - qi = PrintQueueItem( - printer_id=printer_id, # Can be None for unassigned items - archive_id=archive_id, - project_id=project_id, - position=qi_data.get("position", 0), - require_previous_success=qi_data.get("require_previous_success", False), - auto_off_after=qi_data.get("auto_off_after", False), - manual_start=qi_data.get("manual_start", False), - ams_mapping=qi_data.get("ams_mapping"), - plate_id=qi_data.get("plate_id"), - bed_levelling=qi_data.get("bed_levelling", True), - flow_cali=qi_data.get("flow_cali", False), - vibration_cali=qi_data.get("vibration_cali", True), - layer_inspect=qi_data.get("layer_inspect", False), - timelapse=qi_data.get("timelapse", False), - use_ams=qi_data.get("use_ams", True), - status=qi_data.get("status", "pending"), - error_message=qi_data.get("error_message"), - ) - if qi_data.get("scheduled_time"): - qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"]) - if qi_data.get("started_at"): - qi.started_at = datetime.fromisoformat(qi_data["started_at"]) - if qi_data.get("completed_at"): - qi.completed_at = datetime.fromisoformat(qi_data["completed_at"]) - db.add(qi) - restored["print_queue"] += 1 - - # Restore pending uploads (skip duplicates by filename) - if "pending_uploads" in backup: - # Ensure the pending uploads directory exists - pending_uploads_dir = base_dir / "virtual_printer" / "uploads" - pending_uploads_dir.mkdir(parents=True, exist_ok=True) - - for upload_data in backup["pending_uploads"]: - # Check for existing by filename - result = await db.execute( - select(PendingUpload).where( - PendingUpload.filename == upload_data["filename"], - PendingUpload.status == "pending", - ) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update existing - existing.file_size = upload_data.get("file_size", 0) - existing.source_ip = upload_data.get("source_ip") - existing.tags = upload_data.get("tags") - existing.notes = upload_data.get("notes") - existing.project_id = upload_data.get("project_id") - # Update file path if file was restored from ZIP - if upload_data.get("file_path"): - restored_file = base_dir / upload_data["file_path"] - if restored_file.exists(): - # Move to proper location - target_path = pending_uploads_dir / upload_data["filename"] - if restored_file != target_path: - import shutil - - shutil.move(str(restored_file), str(target_path)) - existing.file_path = str(target_path) - restored["pending_uploads"] += 1 - else: - skipped["pending_uploads"] += 1 - skipped_details["pending_uploads"].append(upload_data["filename"]) - else: - # Determine file path - file_path_str = None - if upload_data.get("file_path"): - restored_file = base_dir / upload_data["file_path"] - if restored_file.exists(): - # Move to proper location - target_path = pending_uploads_dir / upload_data["filename"] - if restored_file != target_path: - import shutil - - shutil.move(str(restored_file), str(target_path)) - file_path_str = str(target_path) - - # Parse uploaded_at - uploaded_at = None - if upload_data.get("uploaded_at"): - try: - uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00")) - except (ValueError, AttributeError): - uploaded_at = datetime.utcnow() - else: - uploaded_at = datetime.utcnow() - - pending = PendingUpload( - filename=upload_data["filename"], - file_path=file_path_str or "", - file_size=upload_data.get("file_size", 0), - source_ip=upload_data.get("source_ip"), - status="pending", - tags=upload_data.get("tags"), - notes=upload_data.get("notes"), - project_id=upload_data.get("project_id"), - uploaded_at=uploaded_at, - ) - db.add(pending) - restored["pending_uploads"] += 1 - - # Restore API keys (generates new keys since we can't restore the hash) - new_api_keys: list[dict] = [] # Track newly generated keys for response - if "api_keys" in backup: - from backend.app.core.auth import generate_api_key - - # Build printer serial to ID mapping - printer_serial_to_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - restored["api_keys"] = 0 - skipped["api_keys"] = 0 - skipped_details["api_keys"] = [] - - for key_data in backup["api_keys"]: - # Check if key with same name already exists - result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update permissions but keep the existing key - existing.can_queue = key_data.get("can_queue", True) - existing.can_control_printer = key_data.get("can_control_printer", False) - existing.can_read_status = key_data.get("can_read_status", True) - existing.enabled = key_data.get("enabled", True) - if key_data.get("expires_at"): - existing.expires_at = datetime.fromisoformat(key_data["expires_at"]) - # Convert printer serials to IDs - if key_data.get("printer_serials"): - existing.printer_ids = [ - printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id - ] - restored["api_keys"] += 1 - else: - skipped["api_keys"] += 1 - skipped_details["api_keys"].append(key_data["name"]) - else: - # Generate new key - full_key, key_hash, key_prefix = generate_api_key() - - # Convert printer serials to IDs - printer_ids = None - if key_data.get("printer_serials"): - printer_ids = [ - printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id - ] - - api_key = APIKey( - name=key_data["name"], - key_hash=key_hash, - key_prefix=key_prefix, - can_queue=key_data.get("can_queue", True), - can_control_printer=key_data.get("can_control_printer", False), - can_read_status=key_data.get("can_read_status", True), - printer_ids=printer_ids, - enabled=key_data.get("enabled", True), - ) - if key_data.get("expires_at"): - api_key.expires_at = datetime.fromisoformat(key_data["expires_at"]) - db.add(api_key) - restored["api_keys"] += 1 - - # Track the new key so user can see it - new_api_keys.append( - { - "name": key_data["name"], - "key": full_key, - "key_prefix": key_prefix, - } - ) - - # Restore groups (before users, so groups exist for assignment) - if "groups" in backup: - for group_data in backup["groups"]: - result = await db.execute(select(Group).where(Group.name == group_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite and not existing.is_system: - # Update non-system groups - existing.description = group_data.get("description") - existing.permissions = group_data.get("permissions", []) - restored["groups"] += 1 - else: - skipped["groups"] += 1 - skipped_details["groups"].append(group_data["name"]) - else: - group = Group( - name=group_data["name"], - description=group_data.get("description"), - permissions=group_data.get("permissions", []), - is_system=group_data.get("is_system", False), - ) - db.add(group) - restored["groups"] += 1 - - # Flush to ensure groups are persisted before user assignment - await db.flush() - - # Build group name to object lookup for user assignment - group_name_to_obj: dict[str, Group] = {} - result = await db.execute(select(Group)) - for g in result.scalars().all(): - group_name_to_obj[g.name] = g - - # Restore users (note: passwords not included in backup - users will need new passwords) - # Users are skipped by default since they have no passwords; admin must recreate them - new_users: list[str] = [] - if "users" in backup: - from backend.app.core.auth import get_password_hash - - for user_data in backup["users"]: - result = await db.execute(select(User).where(User.username == user_data["username"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.role = user_data.get("role", "user") - existing.is_active = user_data.get("is_active", True) - # Assign groups if provided - group_names = user_data.get("groups", []) - if group_names: - existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj] - # Don't change password - keep existing - restored["users"] += 1 - else: - skipped["users"] += 1 - skipped_details["users"].append(user_data["username"]) - else: - # Create user with a temporary password that must be changed - # Generate a random temporary password - import secrets - - temp_password = secrets.token_urlsafe(16) - user = User( - username=user_data["username"], - password_hash=get_password_hash(temp_password), - role=user_data.get("role", "user"), - is_active=user_data.get("is_active", True), - ) - # Assign groups if provided - group_names = user_data.get("groups", []) - if group_names: - user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj] - db.add(user) - restored["users"] += 1 - new_users.append(f"{user_data['username']} (temp password: {temp_password})") - - # Restore GitHub backup configuration (note: access_token not included for security) - if "github_backup" in backup: - github_data = backup["github_backup"] - result = await db.execute(select(GitHubBackupConfig).limit(1)) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.repository_url = github_data.get("repository_url", existing.repository_url) - existing.branch = github_data.get("branch", existing.branch) - existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled) - existing.schedule_type = github_data.get("schedule_type", existing.schedule_type) - existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles) - existing.backup_cloud_profiles = github_data.get( - "backup_cloud_profiles", existing.backup_cloud_profiles - ) - existing.backup_settings = github_data.get("backup_settings", existing.backup_settings) - existing.enabled = github_data.get("enabled", existing.enabled) - # Note: access_token must be re-entered after restore - restored["github_backup"] += 1 - else: - skipped["github_backup"] += 1 - else: - config = GitHubBackupConfig( - repository_url=github_data.get("repository_url", ""), - access_token="", # Must be entered after restore - branch=github_data.get("branch", "main"), - schedule_enabled=github_data.get("schedule_enabled", False), - schedule_type=github_data.get("schedule_type", "daily"), - backup_kprofiles=github_data.get("backup_kprofiles", True), - backup_cloud_profiles=github_data.get("backup_cloud_profiles", True), - backup_settings=github_data.get("backup_settings", False), - enabled=False, # Disabled until token is entered - ) - db.add(config) - restored["github_backup"] += 1 - - await db.commit() - - # If printers were in the backup (restored, updated, or skipped), reconnect all active printers - # This ensures connections are re-established after restore, even if printers were skipped - if "printers" in backup: - # Need fresh query after commit to get proper IDs for newly created printers - result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) - active_printers = result.scalars().all() - for printer in active_printers: - # This will disconnect existing connection (if any) and reconnect - try: - await printer_manager.connect_printer(printer) - except Exception: - pass # Connection failed, but don't fail the restore - - # If settings were restored, check if Spoolman needs to be reconnected - if "settings" in backup: - spoolman_enabled = await get_setting(db, "spoolman_enabled") - spoolman_url = await get_setting(db, "spoolman_url") - if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url: - try: - client = await init_spoolman_client(spoolman_url) - if await client.health_check(): - pass # Connected successfully - except Exception: - pass # Spoolman connection failed, but don't fail the restore - - # Reconfigure virtual printer if settings were restored try: - from backend.app.services.virtual_printer import virtual_printer_manager + with zipfile.ZipFile(io.BytesIO(content), "r") as zf: + zf.extractall(temp_path) + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid backup file: not a valid ZIP") - vp_enabled = await get_setting(db, "virtual_printer_enabled") - vp_access_code = await get_setting(db, "virtual_printer_access_code") - vp_mode = await get_setting(db, "virtual_printer_mode") - vp_model = await get_setting(db, "virtual_printer_model") + # 2. Validate backup (must have database) + backup_db = temp_path / "bambuddy.db" + if not backup_db.exists(): + raise HTTPException(400, "Invalid backup: missing bambuddy.db") - enabled = vp_enabled and vp_enabled.lower() == "true" - access_code = vp_access_code or "" - mode = vp_mode or "immediate" - model = vp_model or "" + # 3. Close current database connections + await close_all_connections() - if enabled and access_code: - await virtual_printer_manager.configure( - enabled=True, - access_code=access_code, - mode=mode, - model=model, - ) - elif not enabled and virtual_printer_manager.is_enabled: - await virtual_printer_manager.configure( - enabled=False, - access_code=access_code, - mode=mode, - model=model, - ) - except Exception: - pass # Virtual printer config failed, but don't fail the restore + # 4. Replace database + shutil.copy2(backup_db, db_path) - # Reconfigure MQTT relay if settings were restored - try: - from backend.app.services.mqtt_relay import mqtt_relay + # 5. Replace data directories + dirs_to_restore = [ + ("archive", base_dir / "archive"), + ("virtual_printer", base_dir / "virtual_printer"), + ("plate_calibration", app_settings.plate_calibration_dir), + ("icons", base_dir / "icons"), + ("projects", base_dir / "projects"), + ] - mqtt_settings = { - "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true", - "mqtt_broker": await get_setting(db, "mqtt_broker") or "", - "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"), - "mqtt_username": await get_setting(db, "mqtt_username") or "", - "mqtt_password": await get_setting(db, "mqtt_password") or "", - "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy", - "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true", - } - await mqtt_relay.configure(mqtt_settings) - except Exception: - pass # MQTT relay config failed, but don't fail the restore + for name, dest_dir in dirs_to_restore: + src_dir = temp_path / name + if src_dir.exists(): + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(src_dir, dest_dir) - # Build summary message - restored_parts = [] - for key, count in restored.items(): - if count > 0: - restored_parts.append(f"{count} {key.replace('_', ' ')}") + # 6. Note: Database connection will be reinitialized on restart + # The application should be restarted after restore - if files_restored > 0: - restored_parts.append(f"{files_restored} files") - - skipped_parts = [] - total_skipped = sum(skipped.values()) - for key, count in skipped.items(): - if count > 0: - skipped_parts.append(f"{count} {key.replace('_', ' ')}") - - message_parts = [] - if restored_parts: - message_parts.append(f"Restored: {', '.join(restored_parts)}") - if skipped_parts: - message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}") - - response = { - "success": True, - "message": ". ".join(message_parts) if message_parts else "Nothing to restore", - "restored": restored, - "skipped": skipped, - "skipped_details": skipped_details, - "files_restored": files_restored, - "total_skipped": total_skipped, - } - - # Include newly generated API keys if any (so user can see them) - if new_api_keys: - response["new_api_keys"] = new_api_keys - - # Include newly created users with temp passwords (so admin can share them) - if new_users: - response["new_users"] = new_users - - return response - - -# ============================================================================= -# Virtual Printer Settings -# ============================================================================= + return { + "success": True, + "message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.", + } @router.get("/virtual-printer/models") diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index 8cdc78c03..1a42dc05f 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -410,6 +410,10 @@ def _sanitize_log_content(content: str) -> str: # Replace email addresses content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content) + # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total) + # These appear in logs as [SERIAL] or in messages + content = re.sub(r"\b(0[0-3][A-Z0-9])[A-Z0-9]{9,13}\b", r"\1[SERIAL]", content) + # Replace paths with usernames content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content) content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index ecbcf2426..372e7e188 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -11,7 +11,10 @@ from backend.app.core.auth import ( ) from backend.app.core.database import get_db from backend.app.core.permissions import Permission +from backend.app.models.archive import PrintArchive from backend.app.models.group import Group +from backend.app.models.library import LibraryFile +from backend.app.models.print_queue import PrintQueueItem from backend.app.models.user import User from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate @@ -198,13 +201,55 @@ async def update_user( return _user_to_response(user) +@router.get("/{user_id}/items-count") +async def get_user_items_count( + user_id: int, + _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ), + db: AsyncSession = Depends(get_db), +): + """Get count of items created by this user.""" + # Verify user exists + result = await db.execute(select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Count archives + archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id)) + archives_count = archives_result.scalar() or 0 + + # Count queue items + queue_result = await db.execute( + select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id) + ) + queue_items_count = queue_result.scalar() or 0 + + # Count library files + library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id)) + library_files_count = library_result.scalar() or 0 + + return { + "archives": archives_count, + "queue_items": queue_items_count, + "library_files": library_files_count, + } + + @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: int, + delete_items: bool = Query(False, description="Delete all items created by this user"), current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE), db: AsyncSession = Depends(get_db), ): - """Delete a user.""" + """Delete a user. + + If delete_items=True, all archives, queue items, and library files created by + this user will also be deleted. Otherwise, these items will become "ownerless" + (created_by_id set to NULL by the foreign key constraint). + """ result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups))) user = result.scalar_one_or_none() if not user: @@ -241,6 +286,22 @@ async def delete_user( detail="Cannot delete your own account", ) + if delete_items: + # Delete all items created by this user + await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id)) + await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id)) + await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id)) + else: + # Explicitly set created_by_id to NULL for all items (ensures consistent behavior + # across different database backends, including SQLite without foreign key support) + from sqlalchemy import update + + await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None)) + await db.execute( + update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None) + ) + await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None)) + await db.delete(user) await db.commit() diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index f42a279a7..d72b65091 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -470,3 +470,79 @@ def RequirePermission(*permissions: str | Permission): def RequirePermissionIfAuthEnabled(*permissions: str | Permission): """Convenience dependency that requires permissions if auth is enabled.""" return Depends(require_permission_if_auth_enabled(*permissions)) + + +def require_ownership_permission( + all_permission: str | Permission, + own_permission: str | Permission, +): + """Dependency factory for ownership-based permission checks. + + - User with `all_permission` can modify any item + - User with `own_permission` can only modify items where created_by_id == user.id + - Ownerless items (created_by_id = null) require `all_permission` + + Returns: + A dependency function that returns (user, can_modify_all). + - can_modify_all=True: user can modify any item + - can_modify_all=False: user can only modify their own items + """ + all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission + own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission + + async def checker( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None, + ) -> tuple[User | None, bool]: + """Returns (user, can_modify_all). + + - can_modify_all=True: user can modify any item + - can_modify_all=False: user can only modify their own items + """ + async with async_session() as db: + auth_enabled = await is_auth_enabled(db) + if not auth_enabled: + return None, True # Auth disabled, allow all + + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + token = credentials.credentials + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = await get_user_by_username(db, username) + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if user.has_permission(all_perm): + return user, True + if user.has_permission(own_perm): + return user, False + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing permission: {own_perm} or {all_perm}", + ) + + return checker diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 473bb521f..adc037119 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.1.6" +APP_VERSION = "0.1.7b" GITHUB_REPO = "maziggy/bambuddy" # App directory - where the application is installed (for static files) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 19a42a0e7..8635bf2f5 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -15,6 +15,26 @@ async_session = async_sessionmaker( ) +async def close_all_connections(): + """Close all database connections for backup/restore operations.""" + global engine + await engine.dispose() + + +async def reinitialize_database(): + """Reinitialize database connection after restore.""" + global engine, async_session + engine = create_async_engine( + settings.database_url, + echo=settings.debug, + ) + async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + class Base(DeclarativeBase): pass @@ -1000,6 +1020,60 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add created_by_id column to print_archives for user tracking (Issue #206) + try: + await conn.execute( + text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL") + ) + except Exception: + pass + + # Migration: Add created_by_id column to print_queue for user tracking (Issue #206) + try: + await conn.execute( + text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL") + ) + except Exception: + pass + + # Migration: Add created_by_id column to library_files for user tracking (Issue #206) + try: + await conn.execute( + text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL") + ) + except Exception: + pass + + # Migration: Convert absolute paths to relative paths in library_files table + # This ensures backup/restore portability across different installations + try: + base_dir_str = str(settings.base_dir) + # Ensure we have a trailing slash for clean replacement + if not base_dir_str.endswith("/"): + base_dir_str += "/" + + # Update file_path - remove base_dir prefix from absolute paths + await conn.execute( + text(""" + UPDATE library_files + SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1) + WHERE file_path LIKE :pattern + """), + {"base_dir": base_dir_str, "pattern": base_dir_str + "%"}, + ) + + # Update thumbnail_path - remove base_dir prefix from absolute paths + await conn.execute( + text(""" + UPDATE library_files + SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1) + WHERE thumbnail_path LIKE :pattern + """), + {"base_dir": base_dir_str, "pattern": base_dir_str + "%"}, + ) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" @@ -1046,6 +1120,8 @@ async def seed_default_groups(): don't exist, then migrates existing users: - Users with role='admin' -> Administrators group - Users with role='user' -> Operators group + + Also migrates old permissions to new ownership-based permissions (Issue #205). """ import logging @@ -1057,10 +1133,32 @@ async def seed_default_groups(): logger = logging.getLogger(__name__) + # Map old permissions to new ones for migration + # Administrators get *_all permissions, Operators get *_own permissions + PERMISSION_MIGRATION_ALL = { + "queue:update": "queue:update_all", + "queue:delete": "queue:delete_all", + "archives:update": "archives:update_all", + "archives:delete": "archives:delete_all", + "archives:reprint": "archives:reprint_all", + "library:update": "library:update_all", + "library:delete": "library:delete_all", + } + + PERMISSION_MIGRATION_OWN = { + "queue:update": "queue:update_own", + "queue:delete": "queue:delete_own", + "archives:update": "archives:update_own", + "archives:delete": "archives:delete_own", + "archives:reprint": "archives:reprint_own", + "library:update": "library:update_own", + "library:delete": "library:delete_own", + } + async with async_session() as session: # Get existing groups - result = await session.execute(select(Group.name)) - existing_groups = {row[0] for row in result.fetchall()} + result = await session.execute(select(Group)) + existing_groups = {group.name: group for group in result.scalars().all()} # Create default groups if they don't exist groups_created = [] @@ -1075,12 +1173,50 @@ async def seed_default_groups(): session.add(group) groups_created.append(group_name) logger.info(f"Created default group: {group_name}") + else: + # Migrate existing group's permissions from old to new format + group = existing_groups[group_name] + if group.permissions: + updated = False + new_permissions = list(group.permissions) + + # Determine which migration map to use based on group + migration_map = ( + PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN + ) + + for old_perm, new_perm in migration_map.items(): + if old_perm in new_permissions: + new_permissions.remove(old_perm) + if new_perm not in new_permissions: + new_permissions.append(new_perm) + updated = True + logger.info(f"Migrated permission '{old_perm}' to '{new_perm}' in group '{group_name}'") + + # For Administrators, also ensure they get *_all permissions if they have any new *_own + if group_name == "Administrators": + for _own_perm, all_perm in [ + ("queue:update_own", "queue:update_all"), + ("queue:delete_own", "queue:delete_all"), + ("archives:update_own", "archives:update_all"), + ("archives:delete_own", "archives:delete_all"), + ("archives:reprint_own", "archives:reprint_all"), + ("library:update_own", "library:update_all"), + ("library:delete_own", "library:delete_all"), + ]: + # Add *_all if not present + if all_perm not in new_permissions: + new_permissions.append(all_perm) + updated = True + + if updated: + group.permissions = new_permissions await session.commit() # Migrate existing users to groups if they're not already in any group if groups_created: - # Get the groups we need + # Refresh to get newly created groups admin_result = await session.execute(select(Group).where(Group.name == "Administrators")) admin_group = admin_result.scalar_one_or_none() diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index e30c36888..18b2b7735 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -21,26 +21,34 @@ class Permission(str, Enum): PRINTERS_DELETE = "printers:delete" PRINTERS_CONTROL = "printers:control" # Start/stop/pause/resume prints PRINTERS_FILES = "printers:files" # Send files to printer + PRINTERS_AMS_RFID = "printers:ams_rfid" # Re-read AMS RFID tags # Archives ARCHIVES_READ = "archives:read" ARCHIVES_CREATE = "archives:create" - ARCHIVES_UPDATE = "archives:update" - ARCHIVES_DELETE = "archives:delete" - ARCHIVES_REPRINT = "archives:reprint" # Reprint from archive + ARCHIVES_UPDATE_OWN = "archives:update_own" + ARCHIVES_UPDATE_ALL = "archives:update_all" + ARCHIVES_DELETE_OWN = "archives:delete_own" + ARCHIVES_DELETE_ALL = "archives:delete_all" + ARCHIVES_REPRINT_OWN = "archives:reprint_own" + ARCHIVES_REPRINT_ALL = "archives:reprint_all" # Queue QUEUE_READ = "queue:read" QUEUE_CREATE = "queue:create" - QUEUE_UPDATE = "queue:update" - QUEUE_DELETE = "queue:delete" + QUEUE_UPDATE_OWN = "queue:update_own" + QUEUE_UPDATE_ALL = "queue:update_all" + QUEUE_DELETE_OWN = "queue:delete_own" + QUEUE_DELETE_ALL = "queue:delete_all" QUEUE_REORDER = "queue:reorder" # Library LIBRARY_READ = "library:read" LIBRARY_UPLOAD = "library:upload" - LIBRARY_UPDATE = "library:update" - LIBRARY_DELETE = "library:delete" + LIBRARY_UPDATE_OWN = "library:update_own" + LIBRARY_UPDATE_ALL = "library:update_all" + LIBRARY_DELETE_OWN = "library:delete_own" + LIBRARY_DELETE_ALL = "library:delete_all" # Projects PROJECTS_READ = "projects:read" @@ -152,26 +160,34 @@ PERMISSION_CATEGORIES = { Permission.PRINTERS_DELETE, Permission.PRINTERS_CONTROL, Permission.PRINTERS_FILES, + Permission.PRINTERS_AMS_RFID, ], "Archives": [ Permission.ARCHIVES_READ, Permission.ARCHIVES_CREATE, - Permission.ARCHIVES_UPDATE, - Permission.ARCHIVES_DELETE, - Permission.ARCHIVES_REPRINT, + Permission.ARCHIVES_UPDATE_OWN, + Permission.ARCHIVES_UPDATE_ALL, + Permission.ARCHIVES_DELETE_OWN, + Permission.ARCHIVES_DELETE_ALL, + Permission.ARCHIVES_REPRINT_OWN, + Permission.ARCHIVES_REPRINT_ALL, ], "Queue": [ Permission.QUEUE_READ, Permission.QUEUE_CREATE, - Permission.QUEUE_UPDATE, - Permission.QUEUE_DELETE, + Permission.QUEUE_UPDATE_OWN, + Permission.QUEUE_UPDATE_ALL, + Permission.QUEUE_DELETE_OWN, + Permission.QUEUE_DELETE_ALL, Permission.QUEUE_REORDER, ], "Library": [ Permission.LIBRARY_READ, Permission.LIBRARY_UPLOAD, - Permission.LIBRARY_UPDATE, - Permission.LIBRARY_DELETE, + Permission.LIBRARY_UPDATE_OWN, + Permission.LIBRARY_UPDATE_ALL, + Permission.LIBRARY_DELETE_OWN, + Permission.LIBRARY_DELETE_ALL, ], "Projects": [ Permission.PROJECTS_READ, @@ -291,23 +307,24 @@ DEFAULT_GROUPS = { Permission.PRINTERS_DELETE.value, Permission.PRINTERS_CONTROL.value, Permission.PRINTERS_FILES.value, - # Archives - full access + Permission.PRINTERS_AMS_RFID.value, + # Archives - own items only Permission.ARCHIVES_READ.value, Permission.ARCHIVES_CREATE.value, - Permission.ARCHIVES_UPDATE.value, - Permission.ARCHIVES_DELETE.value, - Permission.ARCHIVES_REPRINT.value, - # Queue - full access + Permission.ARCHIVES_UPDATE_OWN.value, + Permission.ARCHIVES_DELETE_OWN.value, + Permission.ARCHIVES_REPRINT_OWN.value, + # Queue - own items only Permission.QUEUE_READ.value, Permission.QUEUE_CREATE.value, - Permission.QUEUE_UPDATE.value, - Permission.QUEUE_DELETE.value, + Permission.QUEUE_UPDATE_OWN.value, + Permission.QUEUE_DELETE_OWN.value, Permission.QUEUE_REORDER.value, - # Library - full access + # Library - own items only Permission.LIBRARY_READ.value, Permission.LIBRARY_UPLOAD.value, - Permission.LIBRARY_UPDATE.value, - Permission.LIBRARY_DELETE.value, + Permission.LIBRARY_UPDATE_OWN.value, + Permission.LIBRARY_DELETE_OWN.value, # Projects - full access Permission.PROJECTS_READ.value, Permission.PROJECTS_CREATE.value, diff --git a/backend/app/main.py b/backend/app/main.py index d4920b859..5f534411e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1461,6 +1461,9 @@ async def on_print_complete(printer_id: int, data: dict): except Exception as e: logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}") + # Clear current print user tracking (Issue #206) + printer_manager.clear_current_print_user(printer_id) + # MQTT relay - publish print complete try: printer_info = printer_manager.get_printer(printer_id) diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 01e230827..647a81842 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -69,10 +69,15 @@ class PrintArchive(Base): # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + # User tracking (who uploaded/created this archive) + created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + # Relationships printer: Mapped["Printer | None"] = relationship(back_populates="archives") project: Mapped["Project | None"] = relationship(back_populates="archives") + created_by: Mapped["User | None"] = relationship() from backend.app.models.printer import Printer # noqa: E402, F811 from backend.app.models.project import Project # noqa: E402, F811 +from backend.app.models.user import User # noqa: E402, F811 diff --git a/backend/app/models/library.py b/backend/app/models/library.py index 4e0023638..a519a939c 100644 --- a/backend/app/models/library.py +++ b/backend/app/models/library.py @@ -82,6 +82,9 @@ class LibraryFile(Base): # User notes notes: Mapped[str | None] = mapped_column(Text, nullable=True) + # User tracking (Issue #206) + created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) @@ -89,7 +92,9 @@ class LibraryFile(Base): # Relationships folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files") project: Mapped["Project | None"] = relationship() + created_by: Mapped["User | None"] = relationship() from backend.app.models.archive import PrintArchive # noqa: E402, F811 from backend.app.models.project import Project # noqa: E402, F811 +from backend.app.models.user import User # noqa: E402, F811 diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 53c54cb93..72a40b16a 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -68,14 +68,19 @@ class PrintQueueItem(Base): # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + # User tracking (who added this to the queue) + created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + # Relationships printer: Mapped["Printer"] = relationship() archive: Mapped["PrintArchive | None"] = relationship() library_file: Mapped["LibraryFile | None"] = relationship() project: Mapped["Project | None"] = relationship(back_populates="queue_items") + created_by: Mapped["User | None"] = relationship() from backend.app.models.archive import PrintArchive # noqa: E402 from backend.app.models.library import LibraryFile # noqa: E402 from backend.app.models.printer import Printer # noqa: E402 from backend.app.models.project import Project # noqa: E402 +from backend.app.models.user import User # noqa: E402 diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index 0d56b206e..7f736f425 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -40,7 +40,7 @@ class Printer(Base): # Relationships archives: Mapped[list["PrintArchive"]] = relationship(back_populates="printer", cascade="all, delete-orphan") - smart_plug: Mapped["SmartPlug | None"] = relationship(back_populates="printer", uselist=False) + smart_plugs: Mapped[list["SmartPlug"]] = relationship(back_populates="printer") notification_providers: Mapped[list["NotificationProvider"]] = relationship(back_populates="printer") maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship( back_populates="printer", cascade="all, delete-orphan" diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index 1d9160ef9..6a1238bd2 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -50,10 +50,8 @@ class SmartPlug(Base): # Legacy multiplier - kept for backward compatibility mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier - # Link to printer (1:1) - printer_id: Mapped[int | None] = mapped_column( - ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True - ) + # Link to printer (multiple plugs/scripts can be linked to one printer) + printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True) # Automation settings enabled: Mapped[bool] = mapped_column(Boolean, default=True) @@ -100,7 +98,7 @@ class SmartPlug(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # Relationship - printer: Mapped["Printer"] = relationship(back_populates="smart_plug") + printer: Mapped["Printer"] = relationship(back_populates="smart_plugs") from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 42e005007..e42b807cc 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -89,6 +89,10 @@ class ArchiveResponse(BaseModel): created_at: datetime + # User tracking (Issue #206) + created_by_id: int | None = None + created_by_username: str | None = None + @model_validator(mode="after") def compute_object_count(self) -> "ArchiveResponse": """Compute object_count from extra_data.printable_objects if not set.""" diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index 4926af0f3..2fb886f91 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -123,6 +123,10 @@ class FileResponse(BaseModel): duplicates: list[FileDuplicate] | None = None duplicate_count: int = 0 + # User tracking (Issue #206) + created_by_id: int | None = None + created_by_username: str | None = None + created_at: datetime updated_at: datetime @@ -141,6 +145,9 @@ class FileListResponse(BaseModel): thumbnail_path: str | None print_count: int duplicate_count: int = 0 + # User tracking (Issue #206) + created_by_id: int | None = None + created_by_username: str | None = None created_at: datetime # Key metadata fields for display diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 785145126..7d487568e 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -95,6 +95,10 @@ class PrintQueueItemResponse(BaseModel): printer_name: str | None = None print_time_seconds: int | None = None # Estimated print time from archive or library file + # User tracking (Issue #206) + created_by_id: int | None = None + created_by_username: str | None = None + class Config: from_attributes = True diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index d20906f81..840be0999 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -806,8 +806,16 @@ class ArchiveService: printer_id: int | None, source_file: Path, print_data: dict | None = None, + created_by_id: int | None = None, ) -> PrintArchive | None: - """Archive a 3MF file with metadata.""" + """Archive a 3MF file with metadata. + + Args: + printer_id: ID of the printer (optional) + source_file: Path to the 3MF file + print_data: Print data from MQTT (optional) + created_by_id: User ID who created this archive (optional, for user tracking) + """ # Verify printer exists if specified if printer_id is not None: result = await self.db.execute(select(Printer).where(Printer.id == printer_id)) @@ -915,6 +923,7 @@ class ArchiveService: cost=cost, quantity=quantity, extra_data=metadata, + created_by_id=created_by_id, ) self.db.add(archive) @@ -924,8 +933,12 @@ class ArchiveService: return archive async def get_archive(self, archive_id: int) -> PrintArchive | None: - """Get an archive by ID.""" - result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) + """Get an archive by ID with creator loaded.""" + from sqlalchemy.orm import selectinload + + result = await self.db.execute( + select(PrintArchive).options(selectinload(PrintArchive.created_by)).where(PrintArchive.id == archive_id) + ) return result.scalar_one_or_none() async def update_archive_status( @@ -997,7 +1010,9 @@ class ArchiveService: from sqlalchemy.orm import selectinload query = ( - select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc()) + select(PrintArchive) + .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by)) + .order_by(PrintArchive.created_at.desc()) ) if printer_id: diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py index f4832a158..8085db1c3 100644 --- a/backend/app/services/bambu_ftp.py +++ b/backend/app/services/bambu_ftp.py @@ -107,17 +107,32 @@ class BambuFTPClient: f"FTP connecting to {self.ip_address}:{self.FTP_PORT} " f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})" ) - self._ftp = ImplicitFTP_TLS() + self._ftp = ImplicitFTP_TLS(skip_session_reuse=skip_reuse) self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout) logger.debug("FTP connected, logging in as bblp") self._ftp.login("bblp", self.access_code) logger.debug("FTP logged in, setting prot_p and passive mode") self._ftp.prot_p() self._ftp.set_pasv(True) - logger.info(f"FTP connected successfully to {self.ip_address}") + # Log welcome message for debugging + if hasattr(self._ftp, "welcome") and self._ftp.welcome: + logger.debug(f"FTP server welcome: {self._ftp.welcome}") + logger.info(f"FTP connected successfully to {self.ip_address} (model={self.printer_model})") return True + except ftplib.error_perm as e: + logger.warning(f"FTP connection permission error to {self.ip_address}: {e}") + self._ftp = None + return False + except TimeoutError as e: + logger.warning(f"FTP connection timed out to {self.ip_address}: {e}") + self._ftp = None + return False + except ssl.SSLError as e: + logger.warning(f"FTP SSL error connecting to {self.ip_address}: {e}") + self._ftp = None + return False except Exception as e: - logger.warning(f"FTP connection failed to {self.ip_address}: {e}") + logger.warning(f"FTP connection failed to {self.ip_address}: {e} (type: {type(e).__name__})") self._ftp = None return False @@ -227,6 +242,62 @@ class BambuFTPClient: pass return False + def diagnose_storage(self) -> dict: + """Run storage diagnostics and return results. For debugging upload issues.""" + results = { + "connected": self._ftp is not None, + "can_list_root": False, + "root_files": [], + "can_list_cache": False, + "storage_info": None, + "pwd": None, + "errors": [], + } + + if not self._ftp: + results["errors"].append("FTP not connected") + return results + + # Try to get current directory + try: + results["pwd"] = self._ftp.pwd() + logger.debug(f"FTP current directory: {results['pwd']}") + except Exception as e: + results["errors"].append(f"PWD failed: {e}") + logger.debug(f"FTP PWD failed: {e}") + + # Try to list root directory + try: + self._ftp.cwd("/") + items = [] + self._ftp.retrlines("LIST", items.append) + results["can_list_root"] = True + results["root_files"] = items[:10] # First 10 entries + logger.debug(f"FTP root listing ({len(items)} items): {items[:5]}") + except Exception as e: + results["errors"].append(f"LIST / failed: {e}") + logger.debug(f"FTP LIST / failed: {e}") + + # Try to list /cache (should exist on all printers) + try: + self._ftp.cwd("/cache") + items = [] + self._ftp.retrlines("LIST", items.append) + results["can_list_cache"] = True + logger.debug(f"FTP /cache listing: {len(items)} items") + except Exception as e: + results["errors"].append(f"LIST /cache failed: {e}") + logger.debug(f"FTP LIST /cache failed: {e}") + + # Try to get storage info + try: + results["storage_info"] = self.get_storage_info() + logger.debug(f"FTP storage info: {results['storage_info']}") + except Exception as e: + results["errors"].append(f"Storage info failed: {e}") + + return results + def upload_file( self, local_path: Path, @@ -242,6 +313,17 @@ class BambuFTPClient: file_size = local_path.stat().st_size if local_path.exists() else 0 logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}") + # Run storage diagnostics before upload (debug) + logger.debug("Running pre-upload storage diagnostics...") + diag = self.diagnose_storage() + logger.info( + f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, " + f"can_list_cache={diag['can_list_cache']}, " + f"storage={diag['storage_info']}, errors={diag['errors']}" + ) + if diag["root_files"]: + logger.debug(f"FTP root directory contents: {diag['root_files']}") + uploaded = 0 def on_block(block: bytes): @@ -254,11 +336,27 @@ class BambuFTPClient: if self._should_skip_session_reuse(): ftplib._SSLSocket = None + logger.debug(f"FTP STOR command starting for {remote_path}") self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block) logger.info(f"FTP upload complete: {remote_path}") return True + except ftplib.error_perm as e: + # Permanent FTP error (4xx/5xx response) + error_code = str(e)[:3] if str(e) else "unknown" + logger.error(f"FTP upload failed for {remote_path}: {e} (error code: {error_code})") + if error_code == "553": + logger.error( + "FTP 553 error - Could not create file. Possible causes: " + "1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), " + "4) Printer busy/not ready, 5) File path issue" + ) + elif error_code == "550": + logger.error("FTP 550 error - File/directory not found or permission denied") + elif error_code == "552": + logger.error("FTP 552 error - Storage quota exceeded (SD card full?)") + return False except Exception as e: - logger.error(f"FTP upload failed for {remote_path}: {e}") + logger.error(f"FTP upload failed for {remote_path}: {e} (type: {type(e).__name__})") return False def upload_bytes(self, data: bytes, remote_path: str) -> bool: diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index d1bb21299..ece44bb81 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -2033,10 +2033,15 @@ class BambuMQTTClient: for tray_id in ams_mapping: # Ensure tray_id is an integer (may be string from JSON) tray_id = int(tray_id) if tray_id is not None else -1 - if tray_id == -1 or tray_id == 255: + if tray_id == -1: + # Unmapped filament slot ams_mapping2.append({"ams_id": 255, "slot_id": 255}) + elif tray_id >= 254: + # External spool: 254 = main nozzle, 255 = deputy nozzle + # External spools use ams_id=255 with slot_id matching tray_id + ams_mapping2.append({"ams_id": 255, "slot_id": tray_id}) else: - # Global tray ID = (ams_id * 4) + slot_id + # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id ams_id = tray_id // 4 slot_id = tray_id % 4 ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id}) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index ed8563fff..b5fe25809 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -857,17 +857,25 @@ class PrintScheduler: # Get FTP retry settings ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings() + logger.info( + f"Queue item {item.id}: FTP upload starting - printer={printer.name} ({printer.model}), " + f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, " + f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}" + ) + # Delete existing file if present (avoids 553 error on overwrite) try: - await delete_file_async( + logger.debug(f"Queue item {item.id}: Deleting existing file {remote_path} if present...") + delete_result = await delete_file_async( printer.ip_address, printer.access_code, remote_path, socket_timeout=ftp_timeout, printer_model=printer.model, ) - except Exception: - pass # File may not exist, that's fine + logger.debug(f"Queue item {item.id}: Delete result: {delete_result}") + except Exception as e: + logger.debug(f"Queue item {item.id}: Delete failed (may not exist): {e}") try: if ftp_retry_enabled: @@ -894,14 +902,21 @@ class PrintScheduler: ) except Exception as e: uploaded = False - logger.error(f"Queue item {item.id}: FTP error: {e}") + logger.error(f"Queue item {item.id}: FTP error: {e} (type: {type(e).__name__})") if not uploaded: + error_msg = ( + "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). " + "See server logs for detailed diagnostics." + ) item.status = "failed" - item.error_message = "Failed to upload file to printer" + item.error_message = error_msg item.completed_at = datetime.utcnow() await db.commit() - logger.error(f"Queue item {item.id}: FTP upload failed") + logger.error( + f"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, " + f"ip={printer.ip_address}. Check logs above for storage diagnostics and specific error codes." + ) # Send failure notification await notification_service.on_queue_job_failed( @@ -911,7 +926,6 @@ class PrintScheduler: reason="Failed to upload file to printer", db=db, ) - await self._power_off_if_needed(db, item) return diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 88f3d9933..c02ea743b 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -98,11 +98,25 @@ class PrinterManager: self._on_ams_change: Callable[[int, list], None] | None = None self._on_layer_change: Callable[[int, int], None] | None = None self._loop: asyncio.AbstractEventLoop | None = None + # Track who started the current print (Issue #206) + self._current_print_user: dict[int, dict] = {} # {printer_id: {"user_id": int, "username": str}} def get_printer(self, printer_id: int) -> PrinterInfo | None: """Get printer info by ID.""" return self._printer_info.get(printer_id) + def set_current_print_user(self, printer_id: int, user_id: int, username: str): + """Track who started the current print (Issue #206).""" + self._current_print_user[printer_id] = {"user_id": user_id, "username": username} + + def get_current_print_user(self, printer_id: int) -> dict | None: + """Get the user who started the current print (Issue #206).""" + return self._current_print_user.get(printer_id) + + def clear_current_print_user(self, printer_id: int): + """Clear the current print user when print completes (Issue #206).""" + self._current_print_user.pop(printer_id, None) + def set_event_loop(self, loop: asyncio.AbstractEventLoop): """Set the event loop for async callbacks.""" self._loop = loop diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index 3225eb0c0..bacd4888d 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -254,6 +254,74 @@ class TestLibraryFilesAPI: assert result["total_folders"] == 2 assert result["total_files"] == 1 + @pytest.mark.asyncio + @pytest.mark.integration + async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session): + """Verify file list response includes user tracking fields (Issue #206).""" + lib_file = await file_factory(filename="test.3mf") + response = await async_client.get("/api/v1/library/files?include_root=false") + assert response.status_code == 200 + result = response.json() + assert len(result) >= 1 + # Find our test file + test_file = next((f for f in result if f["id"] == lib_file.id), None) + assert test_file is not None + # User tracking fields should be present (even if null) + assert "created_by_id" in test_file + assert "created_by_username" in test_file + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session): + """Verify file detail response includes user tracking fields (Issue #206).""" + lib_file = await file_factory(filename="test_detail.3mf") + response = await async_client.get(f"/api/v1/library/files/{lib_file.id}") + assert response.status_code == 200 + result = response.json() + # User tracking fields should be present (even if null) + assert "created_by_id" in result + assert "created_by_username" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session): + """Verify file created with user shows username in response (Issue #206).""" + from backend.app.models.library import LibraryFile + from backend.app.models.user import User + + # Create a test user + user = User(username="testuploader", password_hash="fakehash", role="user") + db_session.add(user) + await db_session.flush() + + # Create a file with created_by_id set + lib_file = LibraryFile( + filename="user_uploaded.3mf", + file_path="/test/user_uploaded.3mf", + file_size=2048, + file_type="3mf", + created_by_id=user.id, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + # Verify file detail shows username + response = await async_client.get(f"/api/v1/library/files/{lib_file.id}") + assert response.status_code == 200 + result = response.json() + assert result["created_by_id"] == user.id + assert result["created_by_username"] == "testuploader" + + # Verify file list also shows username + response = await async_client.get("/api/v1/library/files?include_root=false") + assert response.status_code == 200 + files = response.json() + test_file = next((f for f in files if f["id"] == lib_file.id), None) + assert test_file is not None + assert test_file["created_by_id"] == user.id + assert test_file["created_by_username"] == "testuploader" + class TestLibraryAddToQueueAPI: """Integration tests for /api/v1/library/files/add-to-queue endpoint.""" @@ -715,3 +783,68 @@ endsolid cube""" file_ids = {r["file_id"] for r in result["results"]} assert stl_without_thumb1.id in file_ids assert stl_without_thumb2.id in file_ids + + +class TestLibraryPathHelpers: + """Tests for path handling utilities used for backup portability.""" + + def test_to_relative_path_converts_absolute(self): + """Verify absolute paths are converted to relative paths.""" + from backend.app.api.routes.library import to_relative_path + from backend.app.core.config import settings + + base_dir = str(settings.base_dir) + abs_path = f"{base_dir}/archive/library/files/test.3mf" + rel_path = to_relative_path(abs_path) + + assert not rel_path.startswith("/") + assert rel_path == "archive/library/files/test.3mf" + + def test_to_relative_path_handles_path_object(self): + """Verify Path objects are handled correctly.""" + from pathlib import Path + + from backend.app.api.routes.library import to_relative_path + from backend.app.core.config import settings + + abs_path = Path(settings.base_dir) / "archive" / "test.3mf" + rel_path = to_relative_path(abs_path) + + assert not rel_path.startswith("/") + assert rel_path == "archive/test.3mf" + + def test_to_relative_path_returns_empty_for_empty_input(self): + """Verify empty input returns empty string.""" + from backend.app.api.routes.library import to_relative_path + + assert to_relative_path("") == "" + assert to_relative_path(None) == "" + + def test_to_absolute_path_converts_relative(self): + """Verify relative paths are converted to absolute paths.""" + from backend.app.api.routes.library import to_absolute_path + from backend.app.core.config import settings + + rel_path = "archive/library/files/test.3mf" + abs_path = to_absolute_path(rel_path) + + assert abs_path is not None + assert abs_path.is_absolute() + assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf" + + def test_to_absolute_path_handles_already_absolute(self): + """Verify already absolute paths are returned as-is (for backwards compatibility).""" + from backend.app.api.routes.library import to_absolute_path + + abs_path_str = "/data/archive/test.3mf" + result = to_absolute_path(abs_path_str) + + assert result is not None + assert str(result) == abs_path_str + + def test_to_absolute_path_returns_none_for_empty(self): + """Verify None/empty input returns None.""" + from backend.app.api.routes.library import to_absolute_path + + assert to_absolute_path(None) is None + assert to_absolute_path("") is None diff --git a/backend/tests/integration/test_ownership_permissions.py b/backend/tests/integration/test_ownership_permissions.py new file mode 100644 index 000000000..2d0063f76 --- /dev/null +++ b/backend/tests/integration/test_ownership_permissions.py @@ -0,0 +1,740 @@ +"""Integration tests for ownership-based permission system. + +Tests the ownership permission model where users can have: +- *_all permissions: can modify any item +- *_own permissions: can only modify items they created +- Ownerless items (created_by_id = null) require *_all permission +""" + +import pytest +from httpx import AsyncClient + + +class TestOwnershipPermissionsSetup: + """Helper fixture class for ownership permission tests.""" + + @pytest.fixture + async def auth_setup(self, async_client: AsyncClient): + """Setup auth with admin, create test users with different permission levels.""" + # Enable auth with admin user + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "ownershipadmin", + "admin_password": "adminpassword123", + }, + ) + + # Login as admin + admin_login = await async_client.post( + "/api/v1/auth/login", + json={"username": "ownershipadmin", "password": "adminpassword123"}, + ) + admin_token = admin_login.json()["access_token"] + admin_user = admin_login.json()["user"] + + # Get group IDs + groups_response = await async_client.get( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + groups = groups_response.json() + operators_group = next(g for g in groups if g["name"] == "Operators") + viewers_group = next(g for g in groups if g["name"] == "Viewers") + + # Create operator user (has *_own permissions) + operator_response = await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "username": "operator1", + "password": "operatorpass123", + "group_ids": [operators_group["id"]], + }, + ) + operator_user = operator_response.json() + + # Login as operator + operator_login = await async_client.post( + "/api/v1/auth/login", + json={"username": "operator1", "password": "operatorpass123"}, + ) + operator_token = operator_login.json()["access_token"] + + # Create second operator (for cross-user tests) + operator2_response = await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "username": "operator2", + "password": "operatorpass123", + "group_ids": [operators_group["id"]], + }, + ) + operator2_user = operator2_response.json() + + operator2_login = await async_client.post( + "/api/v1/auth/login", + json={"username": "operator2", "password": "operatorpass123"}, + ) + operator2_token = operator2_login.json()["access_token"] + + # Create viewer user (has no update/delete permissions) + await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "username": "viewer1", + "password": "viewerpass123", + "group_ids": [viewers_group["id"]], + }, + ) + + viewer_login = await async_client.post( + "/api/v1/auth/login", + json={"username": "viewer1", "password": "viewerpass123"}, + ) + viewer_token = viewer_login.json()["access_token"] + + return { + "admin_token": admin_token, + "admin_user": admin_user, + "operator_token": operator_token, + "operator_user": operator_user, + "operator2_token": operator2_token, + "operator2_user": operator2_user, + "viewer_token": viewer_token, + } + + +class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup): + """Tests for archive ownership-based permissions.""" + + # ======================================================================== + # DELETE permissions + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_admin_can_delete_any_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Admin with *_all permissions can delete any archive.""" + printer = await printer_factory() + # Create archive owned by operator + archive = await archive_factory( + printer.id, + print_name="Operator Archive", + created_by_id=auth_setup["operator_user"]["id"], + ) + + # Admin deletes it + response = await async_client.delete( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_can_delete_own_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Operator with *_own permissions can delete their own archive.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + print_name="My Archive", + created_by_id=auth_setup["operator_user"]["id"], + ) + + response = await async_client.delete( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_delete_others_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Operator with *_own permissions cannot delete another user's archive.""" + printer = await printer_factory() + # Archive created by operator2 + archive = await archive_factory( + printer.id, + print_name="Other's Archive", + created_by_id=auth_setup["operator2_user"]["id"], + ) + + # operator1 tries to delete it + response = await async_client.delete( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + assert "your own" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_delete_ownerless_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Operator with *_own permissions cannot delete ownerless archive.""" + printer = await printer_factory() + # Archive with no owner (legacy data) + archive = await archive_factory( + printer.id, + print_name="Ownerless Archive", + created_by_id=None, + ) + + response = await async_client.delete( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_viewer_cannot_delete_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Viewer with no delete permissions cannot delete any archive.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, print_name="Any Archive") + + response = await async_client.delete( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"}, + ) + + assert response.status_code == 403 + + # ======================================================================== + # UPDATE permissions + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_admin_can_update_any_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Admin can update any archive.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + print_name="Original Name", + created_by_id=auth_setup["operator_user"]["id"], + ) + + response = await async_client.patch( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + json={"print_name": "Admin Updated"}, + ) + + assert response.status_code == 200 + assert response.json()["print_name"] == "Admin Updated" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_can_update_own_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Operator can update their own archive.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + print_name="Original Name", + created_by_id=auth_setup["operator_user"]["id"], + ) + + response = await async_client.patch( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"print_name": "Operator Updated"}, + ) + + assert response.status_code == 200 + assert response.json()["print_name"] == "Operator Updated" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_update_others_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Operator cannot update another user's archive.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + print_name="Other's Archive", + created_by_id=auth_setup["operator2_user"]["id"], + ) + + response = await async_client.patch( + f"/api/v1/archives/{archive.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"print_name": "Attempted Update"}, + ) + + assert response.status_code == 403 + + # ======================================================================== + # REPRINT permissions + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_reprint_others_archive( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Operator cannot reprint another user's archive.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + created_by_id=auth_setup["operator2_user"]["id"], + ) + + response = await async_client.post( + f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + + +class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup): + """Tests for print queue ownership-based permissions.""" + + @pytest.fixture + async def queue_item_factory(self, db_session, printer_factory, archive_factory): + """Factory to create test queue items.""" + + async def _create_item(**kwargs): + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + # Create an archive to link to the queue item + archive = await archive_factory(printer.id) + + defaults = { + "printer_id": printer.id, + "archive_id": archive.id, + "status": "pending", + "position": 0, + } + defaults.update(kwargs) + + item = PrintQueueItem(**defaults) + db_session.add(item) + await db_session.commit() + await db_session.refresh(item) + return item + + return _create_item + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_admin_can_delete_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory): + """Admin can delete any queue item.""" + item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"]) + + response = await async_client.delete( + f"/api/v1/queue/{item.id}", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_can_delete_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory): + """Operator can delete their own queue item.""" + item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"]) + + response = await async_client.delete( + f"/api/v1/queue/{item.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_delete_others_queue_item( + self, async_client: AsyncClient, auth_setup, queue_item_factory + ): + """Operator cannot delete another user's queue item.""" + item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"]) + + response = await async_client.delete( + f"/api/v1/queue/{item.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_can_update_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory): + """Operator can update their own queue item.""" + item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"]) + + response = await async_client.patch( + f"/api/v1/queue/{item.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"position": 10}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_update_others_queue_item( + self, async_client: AsyncClient, auth_setup, queue_item_factory + ): + """Operator cannot update another user's queue item.""" + item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"]) + + response = await async_client.patch( + f"/api/v1/queue/{item.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"position": 10}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_cancel_others_queue_item( + self, async_client: AsyncClient, auth_setup, queue_item_factory + ): + """Operator cannot cancel another user's queue item.""" + item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"]) + + response = await async_client.post( + f"/api/v1/queue/{item.id}/cancel", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_update_skips_non_owned_items(self, async_client: AsyncClient, auth_setup, queue_item_factory): + """Bulk update only updates items the user owns.""" + # Create items owned by different users + own_item = await queue_item_factory( + created_by_id=auth_setup["operator_user"]["id"], + ) + other_item = await queue_item_factory( + created_by_id=auth_setup["operator2_user"]["id"], + ) + + response = await async_client.patch( + "/api/v1/queue/bulk", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={ + "item_ids": [own_item.id, other_item.id], + "manual_start": True, + }, + ) + + assert response.status_code == 200 + result = response.json() + # Should only update the owned item + assert result["updated_count"] == 1 + assert result["skipped_count"] == 1 + + +class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup): + """Tests for library file ownership-based permissions.""" + + @pytest.fixture + async def library_file_factory(self, db_session): + """Factory to create test library files.""" + _counter = [0] + + async def _create_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + defaults = { + "filename": f"test_{_counter[0]}.3mf", + "file_path": f"library/test_{_counter[0]}.3mf", + "file_type": "3mf", + "file_size": 1024, + } + defaults.update(kwargs) + + file = LibraryFile(**defaults) + db_session.add(file) + await db_session.commit() + await db_session.refresh(file) + return file + + return _create_file + + @pytest.fixture + async def library_folder_factory(self, db_session): + """Factory to create test library folders.""" + _counter = [0] + + async def _create_folder(**kwargs): + from backend.app.models.library import LibraryFolder + + _counter[0] += 1 + defaults = { + "name": f"TestFolder_{_counter[0]}", + } + defaults.update(kwargs) + + folder = LibraryFolder(**defaults) + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + return folder + + return _create_folder + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_admin_can_delete_any_library_file(self, async_client: AsyncClient, auth_setup, library_file_factory): + """Admin can delete any library file.""" + file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"]) + + response = await async_client.delete( + f"/api/v1/library/files/{file.id}", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_can_delete_own_library_file( + self, async_client: AsyncClient, auth_setup, library_file_factory + ): + """Operator can delete their own library file.""" + file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"]) + + response = await async_client.delete( + f"/api/v1/library/files/{file.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_delete_others_library_file( + self, async_client: AsyncClient, auth_setup, library_file_factory + ): + """Operator cannot delete another user's library file.""" + file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"]) + + response = await async_client.delete( + f"/api/v1/library/files/{file.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_can_update_own_library_file( + self, async_client: AsyncClient, auth_setup, library_file_factory + ): + """Operator can update their own library file.""" + file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"]) + + response = await async_client.put( + f"/api/v1/library/files/{file.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"filename": "renamed.3mf"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_operator_cannot_update_others_library_file( + self, async_client: AsyncClient, auth_setup, library_file_factory + ): + """Operator cannot update another user's library file.""" + file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"]) + + response = await async_client.put( + f"/api/v1/library/files/{file.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"filename": "renamed.3mf"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory): + """Folders require *_all permission (no ownership tracking on folders).""" + folder = await library_folder_factory(name="TestFolder") + + # Operator cannot delete folder (needs *_all) + response = await async_client.delete( + f"/api/v1/library/folders/{folder.id}", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory): + """Bulk delete only deletes files the user owns.""" + own_file = await library_file_factory( + filename="own.3mf", + created_by_id=auth_setup["operator_user"]["id"], + ) + other_file = await library_file_factory( + filename="other.3mf", + created_by_id=auth_setup["operator2_user"]["id"], + ) + + response = await async_client.post( + "/api/v1/library/bulk-delete", + headers={"Authorization": f"Bearer {auth_setup['operator_token']}"}, + json={"file_ids": [own_file.id, other_file.id], "folder_ids": []}, + ) + + assert response.status_code == 200 + result = response.json() + # Should only delete the owned file; other_file is skipped (but skipped count not in response) + assert result["deleted_files"] == 1 + + +class TestAuthDisabledPermissions: + """Tests that verify all operations are allowed when auth is disabled.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_archive_without_auth( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """When auth is disabled, anyone can delete archives.""" + printer = await printer_factory() + archive = await archive_factory(printer.id) + + response = await async_client.delete(f"/api/v1/archives/{archive.id}") + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_archive_without_auth( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """When auth is disabled, anyone can update archives.""" + printer = await printer_factory() + archive = await archive_factory(printer.id) + + response = await async_client.patch( + f"/api/v1/archives/{archive.id}", + json={"print_name": "Updated Name"}, + ) + + assert response.status_code == 200 + + +class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup): + """Tests for user items count endpoint and deletion with items.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_user_items_count( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Verify items count endpoint returns correct counts.""" + printer = await printer_factory() + user_id = auth_setup["operator_user"]["id"] + + # Create some items for the operator + await archive_factory(printer.id, created_by_id=user_id) + await archive_factory(printer.id, created_by_id=user_id) + + response = await async_client.get( + f"/api/v1/users/{user_id}/items-count", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) + + assert response.status_code == 200 + counts = response.json() + assert counts["archives"] >= 2 + assert "queue_items" in counts + assert "library_files" in counts + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_user_keeps_items( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Verify deleting user without delete_items keeps items (ownerless).""" + printer = await printer_factory() + user_id = auth_setup["operator2_user"]["id"] + + # Create archive for operator2 + archive = await archive_factory(printer.id, created_by_id=user_id) + archive_id = archive.id + + # Delete user without deleting items + response = await async_client.delete( + f"/api/v1/users/{user_id}?delete_items=false", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) + + 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}") + assert archive_response.status_code == 200 + assert archive_response.json()["created_by_id"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_user_with_items( + self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session + ): + """Verify deleting user with delete_items=true removes their items.""" + printer = await printer_factory() + + # Create a new user with items + create_response = await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + json={ + "username": "deletewithitems", + "password": "password123", + }, + ) + user_id = create_response.json()["id"] + + # Create archive for this user + archive = await archive_factory(printer.id, created_by_id=user_id) + archive_id = archive.id + + # Delete user WITH deleting items + response = await async_client.delete( + f"/api/v1/users/{user_id}?delete_items=true", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) + + assert response.status_code == 204 + + # Verify archive was deleted + archive_response = await async_client.get(f"/api/v1/archives/{archive_id}") + assert archive_response.status_code == 404 diff --git a/backend/tests/integration/test_printers_api.py b/backend/tests/integration/test_printers_api.py index ed10edb9d..cc0cd1dd1 100644 --- a/backend/tests/integration/test_printers_api.py +++ b/backend/tests/integration/test_printers_api.py @@ -292,6 +292,47 @@ class TestPrinterDataIntegrity: assert response.json()["status"] == "refresh_requested" mock_pm.request_status_update.assert_called_once_with(printer.id) + # ======================================================================== + # Current print user endpoint (Issue #206) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_current_print_user_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.get("/api/v1/printers/99999/current-print-user") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory): + """Verify empty object returned when no user is tracked.""" + printer = await printer_factory(name="Test Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_current_print_user.return_value = None + + response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user") + + assert response.status_code == 200 + assert response.json() == {} + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory): + """Verify user info is returned when tracked.""" + printer = await printer_factory(name="Test Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"} + + response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user") + + assert response.status_code == 200 + result = response.json() + assert result["user_id"] == 42 + assert result["username"] == "testuser" + class TestPrintControlAPI: """Integration tests for print control endpoints (stop, pause, resume).""" diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index 01c69efad..5fe595101 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/backend/tests/integration/test_settings_api.py @@ -393,85 +393,50 @@ class TestSettingsAPI: # Default is False as defined in schema assert isinstance(result["per_printer_mapping_expanded"], bool) - # ======================================================================== - # Backup/Restore tests - # ======================================================================== + +class TestSimplifiedBackupRestore: + """Integration tests for the simplified backup/restore endpoints (ZIP-based). + + Note: Tests that require actual file operations (backup creation) are skipped + because the test suite uses an in-memory database. These tests focus on + validation and error handling which don't require file I/O. + """ @pytest.mark.asyncio @pytest.mark.integration - async def test_backup_includes_external_camera_settings(self, async_client: AsyncClient, printer_factory): - """Verify backup includes external camera settings for printers.""" - # Create a printer with external camera settings - _printer = await printer_factory( - name="Camera Test Printer", - external_camera_url="/dev/video0", - external_camera_type="usb", - external_camera_enabled=True, - ) + async def test_restore_requires_zip_file(self, async_client: AsyncClient): + """Verify restore rejects non-ZIP files.""" + files = {"file": ("backup.txt", b"not a zip file", "text/plain")} + response = await async_client.post("/api/v1/settings/restore", files=files) - # Request backup with printers - response = await async_client.get("/api/v1/settings/backup?include_printers=true") - - assert response.status_code == 200 - backup = response.json() - - # Find the printer in the backup - assert "printers" in backup - printer_data = next((p for p in backup["printers"] if p["name"] == "Camera Test Printer"), None) - assert printer_data is not None - - # Verify external camera fields are included - assert "external_camera_url" in printer_data - assert "external_camera_type" in printer_data - assert "external_camera_enabled" in printer_data - assert printer_data["external_camera_url"] == "/dev/video0" - assert printer_data["external_camera_type"] == "usb" - assert printer_data["external_camera_enabled"] is True + assert response.status_code == 400 + assert "zip" in response.json()["detail"].lower() @pytest.mark.asyncio @pytest.mark.integration - async def test_restore_external_camera_settings_overwrite(self, async_client: AsyncClient, printer_factory): - """Verify restore with overwrite updates external camera settings.""" + async def test_restore_requires_database_in_zip(self, async_client: AsyncClient): + """Verify restore rejects ZIP without database file.""" import io + import zipfile - # Create a printer without camera settings - printer = await printer_factory( - name="Restore Test", - external_camera_url=None, - external_camera_type=None, - external_camera_enabled=False, - ) + # Create a ZIP without bambuddy.db + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("dummy.txt", "dummy content") + zip_buffer.seek(0) - # Create backup data with camera settings - backup_data = { - "version": "1.0", - "included": ["printers"], - "printers": [ - { - "name": "Restore Test", - "serial_number": printer.serial_number, - "ip_address": printer.ip_address, - "external_camera_url": "/dev/video1", - "external_camera_type": "usb", - "external_camera_enabled": True, - } - ], - } + files = {"file": ("backup.zip", zip_buffer.read(), "application/zip")} + response = await async_client.post("/api/v1/settings/restore", files=files) - # Restore with overwrite - import json + assert response.status_code == 400 + assert "missing bambuddy.db" in response.json()["detail"].lower() - files = {"file": ("backup.json", io.BytesIO(json.dumps(backup_data).encode()), "application/json")} - response = await async_client.post("/api/v1/settings/restore?overwrite=true", files=files) + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_invalid_zip(self, async_client: AsyncClient): + """Verify restore rejects corrupted ZIP files.""" + files = {"file": ("backup.zip", b"not valid zip content", "application/zip")} + response = await async_client.post("/api/v1/settings/restore", files=files) - assert response.status_code == 200 - result = response.json() - assert result["success"] is True - - # Verify the printer was updated - response = await async_client.get(f"/api/v1/printers/{printer.id}") - assert response.status_code == 200 - updated_printer = response.json() - assert updated_printer["external_camera_url"] == "/dev/video1" - assert updated_printer["external_camera_type"] == "usb" - assert updated_printer["external_camera_enabled"] is True + assert response.status_code == 400 + assert "not a valid zip" in response.json()["detail"].lower() diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 25547f08b..bd014c627 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -565,6 +565,66 @@ class TestPrinterManager: assert result["state"] is None mock_instance.disconnect.assert_called_once() + # ======================================================================== + # Tests for current print user tracking (Issue #206) + # ======================================================================== + + def test_set_current_print_user(self, manager): + """Verify current print user can be set.""" + manager.set_current_print_user(1, 42, "testuser") + + assert 1 in manager._current_print_user + assert manager._current_print_user[1]["user_id"] == 42 + assert manager._current_print_user[1]["username"] == "testuser" + + def test_get_current_print_user_returns_user(self, manager): + """Verify get_current_print_user returns the stored user.""" + manager.set_current_print_user(1, 42, "testuser") + + result = manager.get_current_print_user(1) + + assert result is not None + assert result["user_id"] == 42 + assert result["username"] == "testuser" + + def test_get_current_print_user_returns_none_for_unknown(self, manager): + """Verify get_current_print_user returns None for unknown printer.""" + result = manager.get_current_print_user(999) + assert result is None + + def test_clear_current_print_user(self, manager): + """Verify current print user can be cleared.""" + manager.set_current_print_user(1, 42, "testuser") + manager.clear_current_print_user(1) + + result = manager.get_current_print_user(1) + assert result is None + + def test_clear_current_print_user_no_error_for_unknown(self, manager): + """Verify clearing unknown printer doesn't raise error.""" + # Should not raise + manager.clear_current_print_user(999) + + def test_set_current_print_user_overwrites_existing(self, manager): + """Verify setting user overwrites existing value.""" + manager.set_current_print_user(1, 42, "user1") + manager.set_current_print_user(1, 99, "user2") + + result = manager.get_current_print_user(1) + assert result["user_id"] == 99 + assert result["username"] == "user2" + + def test_multiple_printers_have_separate_users(self, manager): + """Verify each printer tracks its own user separately.""" + manager.set_current_print_user(1, 42, "user1") + manager.set_current_print_user(2, 99, "user2") + + result1 = manager.get_current_print_user(1) + result2 = manager.get_current_print_user(2) + + assert result1["username"] == "user1" + assert result2["username"] == "user2" + class TestPrinterStateToDict: """Tests for printer_state_to_dict helper function.""" diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx index 405fb1c82..2babedab5 100644 --- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx +++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx @@ -207,6 +207,139 @@ describe('StreamOverlayPage', () => { }); }); + describe('FPS configuration', () => { + it('uses default FPS of 15 when not specified', async () => { + renderOverlayPage(1); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=15'); + }); + }); + + it('uses custom FPS when specified in query params', async () => { + renderOverlayPage(1, '?fps=30'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=30'); + }); + }); + + it('clamps FPS to maximum of 30', async () => { + renderOverlayPage(1, '?fps=60'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=30'); + }); + }); + + it('clamps FPS to minimum of 1', async () => { + renderOverlayPage(1, '?fps=0'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=1'); + }); + }); + + it('handles invalid FPS value gracefully', async () => { + renderOverlayPage(1, '?fps=invalid'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + // Should fall back to default of 15 + expect(img.src).toContain('fps=15'); + }); + }); + }); + + describe('camera toggle (status-only mode)', () => { + it('shows camera by default', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByAltText('Camera stream')).toBeInTheDocument(); + }); + }); + + it('hides camera when camera=false', async () => { + renderOverlayPage(1, '?camera=false'); + + await waitFor(() => { + // Status should still be visible + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + + // Camera should not be rendered + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + }); + + it('hides camera when camera=0', async () => { + renderOverlayPage(1, '?camera=0'); + + await waitFor(() => { + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + }); + + it('shows camera when camera=true', async () => { + renderOverlayPage(1, '?camera=true'); + + await waitFor(() => { + expect(screen.getByAltText('Camera stream')).toBeInTheDocument(); + }); + }); + + it('shows camera when camera=1', async () => { + renderOverlayPage(1, '?camera=1'); + + await waitFor(() => { + expect(screen.getByAltText('Camera stream')).toBeInTheDocument(); + }); + }); + }); + + describe('combined parameters', () => { + it('supports fps and camera together', async () => { + renderOverlayPage(1, '?fps=25&camera=true'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=25'); + }); + }); + + it('supports status-only with custom size', async () => { + renderOverlayPage(1, '?camera=false&size=large'); + + await waitFor(() => { + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + }); + + it('supports show parameter with fps', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json(mockStatusPrinting); + }) + ); + + renderOverlayPage(1, '?fps=20&show=progress'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=20'); + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + }); + }); + describe('offline state', () => { beforeEach(() => { server.use( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5ad78680d..e26ac48c2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -324,6 +324,9 @@ export interface Archive { energy_kwh: number | null; energy_cost: number | null; created_at: string; + // User tracking (Issue #206) + created_by_id: number | null; + created_by_username: string | null; } export interface ArchiveStats { @@ -1094,6 +1097,9 @@ export interface PrintQueueItem { library_file_thumbnail?: string | null; printer_name?: string | null; print_time_seconds?: number | null; // Estimated print time from archive or library file + // User tracking (Issue #206) + created_by_id?: number | null; + created_by_username?: string | null; } export interface PrintQueueItemCreate { @@ -1721,10 +1727,15 @@ export interface ExternalLinkUpdate { // Permission type - all available permissions export type Permission = - | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' - | 'archives:read' | 'archives:create' | 'archives:update' | 'archives:delete' | 'archives:reprint' - | 'queue:read' | 'queue:create' | 'queue:update' | 'queue:delete' | 'queue:reorder' - | 'library:read' | 'library:upload' | 'library:update' | 'library:delete' + | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' + | 'archives:read' | 'archives:create' + | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all' + | 'archives:reprint_own' | 'archives:reprint_all' + | 'queue:read' | 'queue:create' + | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all' + | 'queue:reorder' + | 'library:read' | 'library:upload' + | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all' | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete' | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete' | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control' @@ -1886,10 +1897,12 @@ export const api = { method: 'PATCH', body: JSON.stringify(data), }), - deleteUser: (id: number) => - request(`/users/${id}`, { + deleteUser: (id: number, deleteItems: boolean = false) => + request(`/users/${id}?delete_items=${deleteItems}`, { method: 'DELETE', }), + getUserItemsCount: (id: number) => + request<{ archives: number; queue_items: number; library_files: number }>(`/users/${id}/items-count`), changePassword: (currentPassword: string, newPassword: string) => request<{ message: string }>('/users/me/change-password', { method: 'POST', @@ -1975,6 +1988,10 @@ export const api = { method: 'POST', }), + // Get current print user (for reprint tracking - Issue #206) + getCurrentPrintUser: (printerId: number) => + request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`), + // Chamber Light Control setChamberLight: (printerId: number, on: boolean) => request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, { @@ -2223,8 +2240,13 @@ export const api = { uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => { const formData = new FormData(); formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -2273,8 +2295,13 @@ export const api = { if (audioFile) { formData.append('audio', audioFile); } + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -2289,7 +2316,12 @@ export const api = { uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => { const formData = new FormData(); formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, { + headers, method: 'POST', body: formData, }); @@ -2311,8 +2343,13 @@ export const api = { uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => { const formData = new FormData(); formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -2331,8 +2368,13 @@ export const api = { uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => { const formData = new FormData(); formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -2461,8 +2503,13 @@ export const api = { const url = printerId ? `${API_BASE}/archives/upload?printer_id=${printerId}` : `${API_BASE}/archives/upload`; + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(url, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -2477,8 +2524,13 @@ export const api = { const url = printerId ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}` : `${API_BASE}/archives/upload-bulk`; + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(url, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -2498,25 +2550,9 @@ export const api = { getMQTTStatus: () => request('/settings/mqtt/status'), resetSettings: () => request('/settings/reset', { method: 'POST' }), - exportBackup: async (categories?: Record): Promise<{ blob: Blob; filename: string }> => { - const params = new URLSearchParams(); - if (categories) { - if (categories.settings !== undefined) params.set('include_settings', String(categories.settings)); - if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications)); - if (categories.templates !== undefined) params.set('include_templates', String(categories.templates)); - if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs)); - if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links)); - if (categories.printers !== undefined) params.set('include_printers', String(categories.printers)); - if (categories.plate_calibration !== undefined) params.set('include_plate_calibration', String(categories.plate_calibration)); - if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments)); - if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance)); - if (categories.archives !== undefined) params.set('include_archives', String(categories.archives)); - if (categories.projects !== undefined) params.set('include_projects', String(categories.projects)); - if (categories.pending_uploads !== undefined) params.set('include_pending_uploads', String(categories.pending_uploads)); - if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes)); - if (categories.api_keys !== undefined) params.set('include_api_keys', String(categories.api_keys)); - } - const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`; + exportBackup: async (): Promise<{ blob: Blob; filename: string }> => { + // New simplified backup - complete database + all files + const url = `${API_BASE}/settings/backup`; const response = await fetch(url); // Check for errors @@ -2527,7 +2563,7 @@ export const api = { // Get filename from Content-Disposition header const contentDisposition = response.headers.get('Content-Disposition'); - let filename = 'bambuddy-backup.json'; + let filename = 'bambuddy-backup.zip'; if (contentDisposition) { const match = contentDisposition.match(/filename=([^;]+)/); if (match) filename = match[1].trim(); @@ -2536,22 +2572,23 @@ export const api = { const blob = await response.blob(); return { blob, filename }; }, - importBackup: async (file: File, overwrite = false) => { + importBackup: async (file: File) => { + // New simplified restore - replaces database + all directories const formData = new FormData(); formData.append('file', file); - const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`; + const url = `${API_BASE}/settings/restore`; + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(url, { method: 'POST', + headers, body: formData, }); return response.json() as Promise<{ success: boolean; message: string; - restored?: Record; - skipped?: Record; - skipped_details?: Record; - files_restored?: number; - total_skipped?: number; }>; }, checkFfmpeg: () => @@ -3047,8 +3084,13 @@ export const api = { uploadExternalLinkIcon: async (id: number, file: File): Promise => { const formData = new FormData(); formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/external-links/${id}/icon`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -3107,8 +3149,13 @@ export const api = { }> => { const formData = new FormData(); formData.append('file', file); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -3225,8 +3272,13 @@ export const api = { const params = new URLSearchParams(); if (folderId) params.set('folder_id', String(folderId)); params.set('generate_stl_thumbnails', String(generateStlThumbnails)); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/library/files?${params}`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -3249,8 +3301,13 @@ export const api = { params.set('preserve_structure', String(preserveStructure)); params.set('create_folder_from_zip', String(createFolderFromZip)); params.set('generate_stl_thumbnails', String(generateStlThumbnails)); + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, { method: 'POST', + headers, body: formData, }); if (!response.ok) { @@ -3550,6 +3607,9 @@ export interface LibraryFile { notes: string | null; duplicates: LibraryFileDuplicate[] | null; duplicate_count: number; + // User tracking (Issue #206) + created_by_id: number | null; + created_by_username: string | null; created_at: string; updated_at: string; } @@ -3563,6 +3623,9 @@ export interface LibraryFileListItem { thumbnail_path: string | null; print_count: number; duplicate_count: number; + // User tracking (Issue #206) + created_by_id: number | null; + created_by_username: string | null; created_at: string; print_name: string | null; print_time_seconds: number | null; diff --git a/frontend/src/components/EmbeddedCameraViewer.tsx b/frontend/src/components/EmbeddedCameraViewer.tsx index c50ba2049..d9715df9d 100644 --- a/frontend/src/components/EmbeddedCameraViewer.tsx +++ b/frontend/src/components/EmbeddedCameraViewer.tsx @@ -461,7 +461,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, } }, [isDragging, isResizing, dragOffset]); - const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`; + const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`; return (
(''); + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); + const [restoreFile, setRestoreFile] = useState(null); + const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null); + const fileInputRef = useRef(null); + + // Block navigation while backup/restore is in progress + useEffect(() => { + const isOperationInProgress = isExporting || isRestoring; + + if (isOperationInProgress) { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?'; + return e.returnValue; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + } + }, [isExporting, isRestoring]); // Test connection state const [testLoading, setTestLoading] = useState(false); @@ -696,80 +717,185 @@ export function GitHubBackupSettings() {

- Export or import your Bambuddy data as a local file for manual backup or migration. + Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.

+ {/* Export */}
-

Export Data

+

Download Backup

- Download all settings, printers, and profiles + Complete backup: database + all files (ZIP)

-
+ {/* Import */} +
-

Import Backup

+

Restore Backup

- Restore from a previous export file + Replace all data from a backup file

+ { + const file = e.target.files?.[0]; + if (file) { + setRestoreFile(file); + setShowRestoreConfirm(true); + } + e.target.value = ''; + }} + />
+ + {/* Restore result message */} + {restoreResult && ( +
+
+ {restoreResult.success ? ( + + ) : ( + + )} +
+ {restoreResult.message} + {restoreResult.success && ( +
+ +
+ )} +
+
+
+ )} + + {/* Warning */} +
+
+ +
+ Restore replaces all data.{' '} + Your current database and files will be completely replaced. A restart is required after restore. +
+
+
- {/* Modals */} - {showBackupModal && ( - setShowBackupModal(false)} - onExport={async (categories) => { - setShowBackupModal(false); + {/* Restore Confirmation Modal */} + {showRestoreConfirm && restoreFile && ( + { + setShowRestoreConfirm(false); + setIsRestoring(true); + setRestoreResult(null); try { - const { blob, filename } = await api.exportBackup(categories); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - showToast('Backup downloaded successfully'); - } catch { - showToast('Failed to create backup', 'error'); + setOperationStatus('Uploading backup file...'); + const result = await api.importBackup(restoreFile); + setRestoreResult(result); + if (result.success) { + showToast('Backup restored. Please restart Bambuddy.', 'success'); + } else { + showToast(result.message, 'error'); + } + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to restore backup'; + setRestoreResult({ success: false, message }); + showToast(message, 'error'); + } finally { + setIsRestoring(false); + setOperationStatus(''); + setRestoreFile(null); } }} + onCancel={() => { + setShowRestoreConfirm(false); + setRestoreFile(null); + }} /> )} - {showRestoreModal && ( - setShowRestoreModal(false)} - onRestore={async (file, overwrite) => { - return await api.importBackup(file, overwrite); - }} - onSuccess={() => { - setShowRestoreModal(false); - showToast('Backup restored successfully'); - queryClient.invalidateQueries(); - }} - /> + {/* Blocking overlay during backup/restore operations */} + {(isExporting || isRestoring) && ( +
+
+
+
+
+
+
+
+

+ {isExporting ? 'Creating Backup' : 'Restoring Backup'} +

+

+ {operationStatus || (isExporting ? 'Preparing...' : 'Processing...')} +

+
+
+ +

+ Please do not close this page or navigate away. This operation may take several minutes for large backups. +

+
+
+
+
)}
); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 4fb9fb2f6..34720ed2e 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -15,6 +15,7 @@ interface AuthContextType { hasPermission: (permission: Permission) => boolean; hasAnyPermission: (...permissions: Permission[]) => boolean; hasAllPermissions: (...permissions: Permission[]) => boolean; + canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; } const AuthContext = createContext(undefined); @@ -156,6 +157,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return permissions.every(p => permissionSet.has(p)); }, [authEnabled, isAdmin, permissionSet]); + // Ownership-based permission check + const canModify = useCallback(( + resource: 'queue' | 'archives' | 'library', + action: 'update' | 'delete' | 'reprint', + createdById: number | null | undefined, + ): boolean => { + if (!authEnabled) return true; // Auth disabled, allow all + if (isAdmin) return true; // Admins can modify anything + + const allPerm = `${resource}:${action}_all` as Permission; + const ownPerm = `${resource}:${action}_own` as Permission; + + // User has *_all permission - can modify any item + if (permissionSet.has(allPerm)) return true; + + // User has *_own permission - can only modify their own items + if (permissionSet.has(ownPerm)) { + // Ownerless items (null created_by_id) require *_all permission + if (createdById == null) return false; + return createdById === user?.id; + } + + return false; + }, [authEnabled, isAdmin, permissionSet, user?.id]); + return ( {children} diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index a820943ba..811acc3b3 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -44,6 +44,7 @@ import { ChevronLeft, ChevronRight, Settings, + User, } from 'lucide-react'; import { api } from '../api/client'; import { openInSlicer } from '../utils/slicer'; @@ -123,7 +124,7 @@ function ArchiveCard({ const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, canModify } = useAuth(); const isMobile = useIsMobile(); const [showViewer, setShowViewer] = useState(false); const [showReprint, setShowReprint] = useState(false); @@ -286,8 +287,8 @@ function ArchiveCard({ label: 'Print', icon: , onClick: () => setShowReprint(true), - disabled: !hasPermission('archives:reprint'), - title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined, + disabled: !canModify('archives', 'reprint', archive.created_by_id), + title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined, }, { label: 'Schedule', @@ -341,8 +342,8 @@ function ArchiveCard({ label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), - disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined, }, { label: '', divider: true, onClick: () => {} }, { @@ -358,30 +359,30 @@ function ArchiveCard({ source3mfInputRef.current?.click(); } }, - disabled: !archive.source_3mf_path && !hasPermission('archives:update'), - title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined, + disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id), + title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }] : []), { label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D', icon: , onClick: () => f3dInputRef.current?.click(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, ...(archive.f3d_path ? [{ label: 'Download F3D', @@ -398,8 +399,8 @@ function ArchiveCard({ icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { @@ -449,15 +450,15 @@ function ArchiveCard({ label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, @@ -468,8 +469,8 @@ function ArchiveCard({ label: 'Add to Project', icon: , onClick: () => {}, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, submenu: (() => { const items: ContextMenuItem[] = []; @@ -479,7 +480,7 @@ function ArchiveCard({ label: 'Remove from Project', icon: , onClick: () => assignProjectMutation.mutate(null), - disabled: !hasPermission('archives:update'), + disabled: !canModify('archives', 'update', archive.created_by_id), }); } @@ -506,7 +507,7 @@ function ArchiveCard({ label: p.name, icon:
, onClick: () => assignProjectMutation.mutate(p.id), - disabled: archive.project_id === p.id || !hasPermission('archives:update'), + disabled: archive.project_id === p.id || !canModify('archives', 'update', archive.created_by_id), }); }); } @@ -526,8 +527,8 @@ function ArchiveCard({ icon: , onClick: () => setShowDeleteConfirm(true), danger: true, - disabled: !hasPermission('archives:delete'), - title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined, + disabled: !canModify('archives', 'delete', archive.created_by_id), + title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined, }, ]; @@ -648,21 +649,21 @@ function ArchiveCard({ {/* Favorite star */} {(archive.status === 'failed' || archive.status === 'aborted') && ( @@ -886,10 +887,18 @@ function ArchiveCard({ {/* Spacer to push content to bottom */}
- {/* Date & Size */} + {/* Date, Size & Creator */}
{formatDateTime(archive.created_at, timeFormat)} - {formatFileSize(archive.file_size)} +
+ {archive.created_by_username && ( + + + {archive.created_by_username} + + )} + {formatFileSize(archive.file_size)} +
{/* Actions */} @@ -902,12 +911,23 @@ function ArchiveCard({ size="sm" className="flex-1 min-w-0" onClick={() => setShowReprint(true)} - disabled={!hasPermission('archives:reprint')} - title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined} + disabled={!canModify('archives', 'reprint', archive.created_by_id)} + title={!canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint' : undefined} > Reprint + @@ -996,8 +1016,8 @@ function ArchiveCard({ size="sm" className="min-w-0 p-1 sm:p-1.5" onClick={() => setShowDeleteConfirm(true)} - disabled={!hasPermission('archives:delete')} - title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'} + disabled={!canModify('archives', 'delete', archive.created_by_id)} + title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'} > @@ -1254,7 +1274,7 @@ function ArchiveListRow({ }) { const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, canModify } = useAuth(); const [showEdit, setShowEdit] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showReprint, setShowReprint] = useState(false); @@ -1399,8 +1419,8 @@ function ArchiveListRow({ label: 'Print', icon: , onClick: () => setShowReprint(true), - disabled: !hasPermission('archives:reprint'), - title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined, + disabled: !canModify('archives', 'reprint', archive.created_by_id), + title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined, }, { label: 'Schedule', @@ -1454,8 +1474,8 @@ function ArchiveListRow({ label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), - disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined, }, { label: '', divider: true, onClick: () => {} }, { @@ -1471,30 +1491,30 @@ function ArchiveListRow({ source3mfInputRef.current?.click(); } }, - disabled: !archive.source_3mf_path && !hasPermission('archives:update'), - title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined, + disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id), + title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }] : []), { label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D', icon: , onClick: () => f3dInputRef.current?.click(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, ...(archive.f3d_path ? [{ label: 'Download F3D', @@ -1511,8 +1531,8 @@ function ArchiveListRow({ icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { @@ -1562,15 +1582,15 @@ function ArchiveListRow({ label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, @@ -1631,8 +1651,8 @@ function ArchiveListRow({ icon: , onClick: () => setShowDeleteConfirm(true), danger: true, - disabled: !hasPermission('archives:delete'), - title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined, + disabled: !canModify('archives', 'delete', archive.created_by_id), + title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined, }, ]; @@ -1720,7 +1740,13 @@ function ArchiveListRow({ {printerName}
- {formatDateOnly(archive.created_at)} +
{formatDateOnly(archive.created_at)}
+ {archive.created_by_username && ( +
+ + {archive.created_by_username} +
+ )}
{formatFileSize(archive.file_size)} @@ -1765,8 +1791,8 @@ function ArchiveListRow({ variant="ghost" size="sm" onClick={() => setShowEdit(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'} + disabled={!canModify('archives', 'update', archive.created_by_id)} + title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'} > @@ -1774,8 +1800,8 @@ function ArchiveListRow({ variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} - disabled={!hasPermission('archives:delete')} - title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'} + disabled={!canModify('archives', 'delete', archive.created_by_id)} + title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'} > @@ -2029,7 +2055,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] = export function ArchivesPage() { const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, hasAnyPermission } = useAuth(); const searchInputRef = useRef(null); const [search, setSearch] = useState(''); const [filterPrinter, setFilterPrinter] = useState(() => { @@ -2445,8 +2471,8 @@ export function ArchivesPage() { variant="secondary" size="sm" onClick={() => setShowBatchTag(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined} + disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')} + title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined} > Tags @@ -2455,8 +2481,8 @@ export function ArchivesPage() { variant="secondary" size="sm" onClick={() => setShowBatchProject(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined} + disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')} + title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined} > Project @@ -2464,8 +2490,8 @@ export function ArchivesPage() {
)} + {file.created_by_username && ( +
+ Uploaded by {file.created_by_username} +
+ )}
{/* Actions - always visible on mobile, hover on desktop */} @@ -991,11 +997,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, {onRename && ( {file.file_type === 'stl' && ( )} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index fce48c8a9..7ad179fb2 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -40,6 +40,7 @@ import { ScanSearch, CheckCircle, XCircle, + User, } from 'lucide-react'; // Custom Skip Objects icon - arrow jumping over boxes @@ -1116,6 +1117,23 @@ function PrinterCard({ }); const queueCount = queueItems?.length || 0; + // Fetch currently printing queue item to show who started it (Issue #206) + const { data: printingQueueItems } = useQuery({ + queryKey: ['queue', printer.id, 'printing'], + queryFn: () => api.getQueue(printer.id, 'printing'), + enabled: status?.state === 'RUNNING', + }); + + // Fetch reprint user info (for prints started via Reprint, not queue - Issue #206) + const { data: reprintUser } = useQuery({ + queryKey: ['currentPrintUser', printer.id], + queryFn: () => api.getCurrentPrintUser(printer.id), + enabled: status?.state === 'RUNNING', + }); + + // Combine both sources: queue item user takes precedence, then reprint user + const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username; + // Fetch last completed print for this printer const { data: lastPrints } = useQuery({ queryKey: ['archives', printer.id, 'last'], @@ -1896,6 +1914,12 @@ function PrinterCard({ {status.layer_num}/{status.total_layers} )} + {currentPrintUser && ( + + + {currentPrintUser} + + )} ) : ( @@ -2267,18 +2291,18 @@ function PrinterCard({
@@ -527,8 +536,8 @@ function SortableQueueItem({ variant="ghost" size="sm" onClick={onCancel} - disabled={!hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : 'Cancel'} + disabled={!canModify('queue', 'delete', item.created_by_id)} + title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'} className="text-red-400 hover:text-red-300 hover:bg-red-500/10" > @@ -551,8 +560,8 @@ function SortableQueueItem({ variant="ghost" size="sm" onClick={onRemove} - disabled={!hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to remove queue items' : 'Remove'} + disabled={!canModify('queue', 'delete', item.created_by_id)} + title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'} > @@ -567,7 +576,7 @@ function SortableQueueItem({ export function QueuePage() { const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, hasAnyPermission, canModify } = useAuth(); const [filterPrinter, setFilterPrinter] = useState(null); const [filterStatus, setFilterStatus] = useState(''); const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false); @@ -921,8 +930,8 @@ export function QueuePage() { variant="secondary" size="sm" onClick={() => setShowClearHistoryConfirm(true)} - disabled={!hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to clear history' : undefined} + disabled={!hasPermission('queue:delete_all')} + title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined} > Clear History @@ -963,6 +972,7 @@ export function QueuePage() { onStart={() => {}} timeFormat={timeFormat} hasPermission={hasPermission} + canModify={canModify} /> ))}
@@ -1032,8 +1042,8 @@ export function QueuePage() { size="sm" onClick={() => setShowBulkEditModal(true)} className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light" - disabled={!hasPermission('queue:update')} - title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : undefined} + disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')} + title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined} > Edit Selected @@ -1043,8 +1053,8 @@ export function QueuePage() { size="sm" onClick={() => bulkCancelMutation.mutate(selectedItems)} className="flex items-center gap-2 text-red-400 hover:text-red-300" - disabled={bulkCancelMutation.isPending || !hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : undefined} + disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')} + title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined} > Cancel Selected @@ -1078,6 +1088,7 @@ export function QueuePage() { isSelected={selectedItems.includes(item.id)} onToggleSelect={() => handleToggleSelect(item.id)} hasPermission={hasPermission} + canModify={canModify} /> ))} @@ -1132,6 +1143,7 @@ export function QueuePage() { onStart={() => {}} timeFormat={timeFormat} hasPermission={hasPermission} + canModify={canModify} /> ))} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4bfe8f32f..ede57364f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -95,6 +95,8 @@ export function SettingsPage() { const [showEditUserModal, setShowEditUserModal] = useState(false); const [editingUserId, setEditingUserId] = useState(null); const [deleteUserId, setDeleteUserId] = useState(null); + const [deleteUserItemCounts, setDeleteUserItemCounts] = useState<{ archives: number; queue_items: number; library_files: number } | null>(null); + const [deleteUserLoading, setDeleteUserLoading] = useState(false); const [userFormData, setUserFormData] = useState<{ username: string; password: string; @@ -355,16 +357,33 @@ export function SettingsPage() { }); const deleteUserMutation = useMutation({ - mutationFn: (id: number) => api.deleteUser(id), + mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); showToast('User deleted successfully'); + setDeleteUserId(null); + setDeleteUserItemCounts(null); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); + // Function to initiate user deletion with item count check + const handleDeleteUserClick = async (userId: number) => { + setDeleteUserId(userId); + setDeleteUserLoading(true); + try { + const counts = await api.getUserItemsCount(userId); + setDeleteUserItemCounts(counts); + } catch { + // If we can't get counts, just proceed without showing item options + setDeleteUserItemCounts({ archives: 0, queue_items: 0, library_files: 0 }); + } finally { + setDeleteUserLoading(false); + } + }; + const createGroupMutation = useMutation({ mutationFn: (data: GroupCreate) => api.createGroup(data), onSuccess: () => { @@ -3488,7 +3507,7 @@ export function SettingsPage() { )} {hasPermission('users:delete') && userItem.id !== user?.id && ( - )} @@ -3892,17 +3911,101 @@ export function SettingsPage() { {/* Delete User Confirmation Modal */} {deleteUserId !== null && ( - { - deleteUserMutation.mutate(deleteUserId); +
{ setDeleteUserId(null); + setDeleteUserItemCounts(null); }} - onCancel={() => setDeleteUserId(null)} - /> + > + e.stopPropagation()} + > + +
+ +

Delete User

+
+
+ + {deleteUserLoading ? ( +
+
+
+ ) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? ( + <> +

This user has created:

+
    + {deleteUserItemCounts.archives > 0 && ( +
  • {deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}
  • + )} + {deleteUserItemCounts.queue_items > 0 && ( +
  • {deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}
  • + )} + {deleteUserItemCounts.library_files > 0 && ( +
  • {deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}
  • + )} +
+

What would you like to do with these items?

+
+ + + +
+ + ) : ( + <> +

Are you sure you want to delete this user?

+

This action cannot be undone.

+
+ + +
+ + )} + + +
)} {/* Create/Edit Group Modal */} diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index abd3dac88..8b245a05f 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -695,8 +695,8 @@ export function StatsPage() {