Merge pull request #219 from maziggy/0.1.7b

Merge fixes
This commit is contained in:
MartinNYHC
2026-02-01 15:23:34 +01:00
committed by GitHub
51 changed files with 3272 additions and 2900 deletions
+55
View File
@@ -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
+2 -1
View File
@@ -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)
</td>
</tr>
+90 -6
View File
@@ -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,
+167 -36
View File
@@ -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:
+76 -6
View File
@@ -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)
+26 -2
View File
@@ -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)."""
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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)
+64 -3
View File
@@ -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()
+76
View File
@@ -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
+1 -1
View File
@@ -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)
+139 -3
View File
@@ -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()
+41 -24
View File
@@ -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,
+3
View File
@@ -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)
+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+1 -1
View File
@@ -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"
+3 -5
View File
@@ -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
+4
View File
@@ -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."""
+7
View File
@@ -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
+4
View File
@@ -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
+19 -4
View File
@@ -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:
+102 -4
View File
@@ -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:
+7 -2
View File
@@ -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})
+21 -7
View File
@@ -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
+14
View File
@@ -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
@@ -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
@@ -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
@@ -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)."""
+34 -69
View File
@@ -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()
@@ -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."""
@@ -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(
+96 -33
View File
@@ -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<void>(`/users/${id}`, {
deleteUser: (id: number, deleteItems: boolean = false) =>
request<void>(`/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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<MQTTStatus>('/settings/mqtt/status'),
resetSettings: () =>
request<AppSettings>('/settings/reset', { method: 'POST' }),
exportBackup: async (categories?: Record<string, boolean>): 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<string, string> = {};
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<string, number>;
skipped?: Record<string, number>;
skipped_details?: Record<string, string[]>;
files_restored?: number;
total_skipped?: number;
}>;
},
checkFfmpeg: () =>
@@ -3047,8 +3084,13 @@ export const api = {
uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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;
@@ -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 (
<div
+169 -43
View File
@@ -16,6 +16,7 @@ import {
SkipForward,
AlertTriangle,
Trash2,
RotateCcw,
} from 'lucide-react';
import { api } from '../api/client';
import type {
@@ -31,8 +32,7 @@ import type {
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { BackupModal } from './BackupModal';
import { RestoreModal } from './RestoreModal';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
interface StatusBadgeProps {
@@ -108,9 +108,30 @@ export function GitHubBackupSettings() {
const [backupSettings, setBackupSettings] = useState(false);
const [enabled, setEnabled] = useState(true);
// Local backup modals
const [showBackupModal, setShowBackupModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
// Local backup state
const [isExporting, setIsExporting] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [operationStatus, setOperationStatus] = useState<string>('');
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
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.
</p>
{/* Export */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Export Data</p>
<p className="text-white">Download Backup</p>
<p className="text-sm text-bambu-gray">
Download all settings, printers, and profiles
Complete backup: database + all files (ZIP)
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowBackupModal(true)}
disabled={isExporting || isRestoring}
onClick={async () => {
setIsExporting(true);
setOperationStatus('Preparing backup...');
try {
setOperationStatus('Creating backup archive... This may take a while for large archives.');
const { blob, filename } = await api.exportBackup();
setOperationStatus('Downloading backup file...');
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 (e) {
showToast(`Failed to create backup: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error');
} finally {
setIsExporting(false);
setOperationStatus('');
}
}}
>
<Download className="w-4 h-4" />
Export
Download
</Button>
</div>
<div className="flex items-center justify-between py-3">
{/* Import */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Import Backup</p>
<p className="text-white">Restore Backup</p>
<p className="text-sm text-bambu-gray">
Restore from a previous export file
Replace all data from a backup file
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setRestoreFile(file);
setShowRestoreConfirm(true);
}
e.target.value = '';
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreModal(true)}
disabled={isRestoring || isExporting}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
Import
Restore
</Button>
</div>
{/* Restore result message */}
{restoreResult && (
<div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
<div className="flex items-start gap-2 text-sm">
{restoreResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
)}
<div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
{restoreResult.message}
{restoreResult.success && (
<div className="mt-2">
<Button
size="sm"
onClick={() => window.location.reload()}
>
<RotateCcw className="w-3 h-3" />
Reload Now
</Button>
</div>
)}
</div>
</div>
</div>
)}
{/* Warning */}
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-yellow-200">
<span className="font-medium">Restore replaces all data.</span>{' '}
<span className="text-yellow-200/70">Your current database and files will be completely replaced. A restart is required after restore.</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Modals */}
{showBackupModal && (
<BackupModal
onClose={() => setShowBackupModal(false)}
onExport={async (categories) => {
setShowBackupModal(false);
{/* Restore Confirmation Modal */}
{showRestoreConfirm && restoreFile && (
<ConfirmModal
title="Restore Backup"
message={`Are you sure you want to restore from "${restoreFile.name}"? This will completely replace your current database and all files. The application will need to be restarted after restore.`}
confirmText="Restore Backup"
variant="danger"
onConfirm={async () => {
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 && (
<RestoreModal
onClose={() => 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) && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
<div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
</div>
</div>
<h3 className="text-xl font-semibold text-white mb-2">
{isExporting ? 'Creating Backup' : 'Restoring Backup'}
</h3>
<p className="text-bambu-gray mb-4">
{operationStatus || (isExporting ? 'Preparing...' : 'Processing...')}
</p>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-yellow-200 text-left">
Please do not close this page or navigate away. This operation may take several minutes for large backups.
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
+27
View File
@@ -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<AuthContextType | undefined>(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 (
<AuthContext.Provider
value={{
@@ -171,6 +197,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
hasPermission,
hasAnyPermission,
hasAllPermissions,
canModify,
}}
>
{children}
+99 -73
View File
@@ -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: <Printer className="w-4 h-4" />,
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: <ScanSearch className="w-4 h-4" />,
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: <Upload className="w-4 h-4" />,
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: <Trash2 className="w-4 h-4" />,
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: <Box className="w-4 h-4" />,
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: <Trash2 className="w-4 h-4" />,
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: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
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: <Pencil className="w-4 h-4" />,
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: <FolderKanban className="w-4 h-4" />,
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: <X className="w-4 h-4" />,
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: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
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: <Trash2 className="w-4 h-4" />,
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 */}
<button
className={`absolute top-2 right-2 p-1 rounded transition-colors ${
hasPermission('archives:update')
canModify('archives', 'update', archive.created_by_id)
? 'bg-black/50 hover:bg-black/70'
: 'bg-black/30 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (hasPermission('archives:update')) {
if (canModify('archives', 'update', archive.created_by_id)) {
favoriteMutation.mutate();
}
}}
disabled={!hasPermission('archives:update')}
title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
disabled={!canModify('archives', 'update', archive.created_by_id)}
title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
>
<Star
className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!hasPermission('archives:update') ? 'opacity-50' : ''}`}
className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!canModify('archives', 'update', archive.created_by_id) ? 'opacity-50' : ''}`}
/>
</button>
{(archive.status === 'failed' || archive.status === 'aborted') && (
@@ -886,10 +887,18 @@ function ArchiveCard({
{/* Spacer to push content to bottom */}
<div className="flex-1" />
{/* Date & Size */}
{/* Date, Size & Creator */}
<div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
<span>{formatDateTime(archive.created_at, timeFormat)}</span>
<span>{formatFileSize(archive.file_size)}</span>
<div className="flex items-center gap-2">
{archive.created_by_username && (
<span className="flex items-center gap-1" title={`Uploaded by ${archive.created_by_username}`}>
<User className="w-3 h-3" />
{archive.created_by_username}
</span>
)}
<span>{formatFileSize(archive.file_size)}</span>
</div>
</div>
{/* 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}
>
<Printer className="w-3 h-3 flex-shrink-0" />
<span className="hidden sm:inline">Reprint</span>
</Button>
<Button
variant="secondary"
size="sm"
className="flex-1 min-w-0"
onClick={() => setShowSchedule(true)}
disabled={!hasPermission('queue:create')}
title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : 'Schedule Print'}
>
<Calendar className="w-3 h-3 flex-shrink-0" />
<span className="hidden sm:inline">Schedule</span>
</Button>
<Button
variant="secondary"
size="sm"
@@ -986,8 +1006,8 @@ function ArchiveCard({
size="sm"
className="min-w-0 p-1 sm:p-1.5"
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'}
>
<Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
</Button>
@@ -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'}
>
<Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
</Button>
@@ -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: <Printer className="w-4 h-4" />,
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: <ScanSearch className="w-4 h-4" />,
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: <Upload className="w-4 h-4" />,
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: <Trash2 className="w-4 h-4" />,
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: <Box className="w-4 h-4" />,
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: <Trash2 className="w-4 h-4" />,
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: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
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: <Pencil className="w-4 h-4" />,
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: <Trash2 className="w-4 h-4" />,
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}
</div>
<div className="col-span-2 text-sm text-bambu-gray">
{formatDateOnly(archive.created_at)}
<div>{formatDateOnly(archive.created_at)}</div>
{archive.created_by_username && (
<div className="flex items-center gap-1 text-xs opacity-75" title={`Uploaded by ${archive.created_by_username}`}>
<User className="w-3 h-3" />
{archive.created_by_username}
</div>
)}
</div>
<div className="col-span-1 text-sm text-bambu-gray">
{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'}
>
<Pencil className="w-4 h-4" />
</Button>
@@ -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'}
>
<Trash2 className="w-4 h-4 text-red-400" />
</Button>
@@ -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<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
@@ -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}
>
<Tag className="w-4 h-4" />
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}
>
<FolderKanban className="w-4 h-4" />
Project
@@ -2464,8 +2490,8 @@ export function ArchivesPage() {
<Button
variant="secondary"
size="sm"
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}
onClick={() => {
const ids = Array.from(selectedIds);
Promise.all(ids.map(id => api.toggleFavorite(id)))
@@ -2485,8 +2511,8 @@ export function ArchivesPage() {
size="sm"
className="bg-red-500 hover:bg-red-600"
onClick={() => setShowBulkDeleteConfirm(true)}
disabled={!hasPermission('archives:delete')}
title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined}
disabled={!hasAnyPermission('archives:delete_own', 'archives:delete_all')}
title={!hasAnyPermission('archives:delete_own', 'archives:delete_all') ? 'You do not have permission to delete archives' : undefined}
>
<Trash2 className="w-4 h-4" />
Delete
+1 -1
View File
@@ -531,7 +531,7 @@ export function CameraPage() {
const currentUrl = transitioning
? ''
: streamMode === 'stream'
? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
? `/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`
: `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
const isDisabled = streamLoading || transitioning || isReconnecting;
+51 -44
View File
@@ -803,33 +803,33 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
<div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onRename(folder); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to rename folders' : undefined}
onClick={() => { if (hasPermission('library:update_all')) { onRename(folder); setShowActions(false); } }}
disabled={!hasPermission('library:update_all')}
title={!hasPermission('library:update_all') ? 'You do not have permission to rename folders' : undefined}
>
<Pencil className="w-3.5 h-3.5" />
Rename
</button>
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onLink(folder); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to link folders' : undefined}
onClick={() => { if (hasPermission('library:update_all')) { onLink(folder); setShowActions(false); } }}
disabled={!hasPermission('library:update_all')}
title={!hasPermission('library:update_all') ? 'You do not have permission to link folders' : undefined}
>
<Link2 className="w-3.5 h-3.5" />
{isLinked ? 'Change Link...' : 'Link to...'}
</button>
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
hasPermission('library:delete_all') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:delete')) { onDelete(folder.id); setShowActions(false); } }}
disabled={!hasPermission('library:delete')}
title={!hasPermission('library:delete') ? 'You do not have permission to delete folders' : undefined}
onClick={() => { if (hasPermission('library:delete_all')) { onDelete(folder.id); setShowActions(false); } }}
disabled={!hasPermission('library:delete_all')}
title={!hasPermission('library:delete_all') ? 'You do not have permission to delete folders' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
Delete
@@ -882,9 +882,10 @@ interface FileCardProps {
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
thumbnailVersion?: number;
hasPermission: (permission: Permission) => boolean;
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
}
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission }: FileCardProps) {
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -937,6 +938,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
Printed {file.print_count}x
</div>
)}
{file.created_by_username && (
<div className="mt-1 text-xs text-bambu-gray">
Uploaded by {file.created_by_username}
</div>
)}
</div>
{/* Actions - always visible on mobile, hover on desktop */}
@@ -991,11 +997,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{onRename && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onRename(file); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to rename files' : undefined}
onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onRename(file); setShowActions(false); } }}
disabled={!canModify('library', 'update', file.created_by_id)}
title={!canModify('library', 'update', file.created_by_id) ? 'You do not have permission to rename this file' : undefined}
>
<Pencil className="w-3.5 h-3.5" />
Rename
@@ -1004,11 +1010,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{onGenerateThumbnail && file.file_type === 'stl' && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onGenerateThumbnail(file); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : undefined}
onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onGenerateThumbnail(file); setShowActions(false); } }}
disabled={!canModify('library', 'update', file.created_by_id)}
title={!canModify('library', 'update', file.created_by_id) ? 'You do not have permission to generate thumbnails' : undefined}
>
<Image className="w-3.5 h-3.5" />
Generate Thumbnail
@@ -1016,11 +1022,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
)}
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
canModify('library', 'delete', file.created_by_id) ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:delete')) { onDelete(file.id); setShowActions(false); } }}
disabled={!hasPermission('library:delete')}
title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
onClick={() => { if (canModify('library', 'delete', file.created_by_id)) { onDelete(file.id); setShowActions(false); } }}
disabled={!canModify('library', 'delete', file.created_by_id)}
title={!canModify('library', 'delete', file.created_by_id) ? 'You do not have permission to delete this file' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
Delete
@@ -1045,7 +1051,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
export function FileManagerPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { hasPermission, hasAnyPermission, canModify } = useAuth();
const [searchParams] = useSearchParams();
// Read folder ID from URL query parameter
@@ -1512,8 +1518,8 @@ export function FileManagerPage() {
<Button
variant="secondary"
onClick={() => batchThumbnailMutation.mutate()}
disabled={batchThumbnailMutation.isPending || !hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
disabled={batchThumbnailMutation.isPending || !hasAnyPermission('library:update_own', 'library:update_all')}
title={!hasAnyPermission('library:update_own', 'library:update_all') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
>
{batchThumbnailMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -1827,8 +1833,8 @@ export function FileManagerPage() {
variant="secondary"
size="sm"
onClick={() => setShowMoveModal(true)}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined}
disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
title={!hasAnyPermission('library:update_own', 'library:update_all') ? 'You do not have permission to move files' : undefined}
>
<MoveRight className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Move</span>
@@ -1843,8 +1849,8 @@ export function FileManagerPage() {
setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
}
}}
disabled={!hasPermission('library:delete')}
title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
disabled={!hasAnyPermission('library:delete_own', 'library:delete_all')}
title={!hasAnyPermission('library:delete_own', 'library:delete_all') ? 'You do not have permission to delete files' : undefined}
>
<Trash2 className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Delete</span>
@@ -1924,6 +1930,7 @@ export function FileManagerPage() {
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
thumbnailVersion={thumbnailVersions[file.id]}
hasPermission={hasPermission}
canModify={canModify}
/>
))}
</div>
@@ -2048,40 +2055,40 @@ export function FileManagerPage() {
<Download className="w-4 h-4" />
</button>
<button
onClick={() => hasPermission('library:update') && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:update')
canModify('library', 'update', file.created_by_id)
? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:update') ? 'Rename' : 'You do not have permission to rename files'}
disabled={!hasPermission('library:update')}
title={canModify('library', 'update', file.created_by_id) ? 'Rename' : 'You do not have permission to rename this file'}
disabled={!canModify('library', 'update', file.created_by_id)}
>
<Pencil className="w-4 h-4" />
</button>
{file.file_type === 'stl' && (
<button
onClick={() => hasPermission('library:update') && singleThumbnailMutation.mutate(file.id)}
onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:update')
canModify('library', 'update', file.created_by_id)
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:update') ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
disabled={singleThumbnailMutation.isPending || !hasPermission('library:update')}
title={canModify('library', 'update', file.created_by_id) ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
disabled={singleThumbnailMutation.isPending || !canModify('library', 'update', file.created_by_id)}
>
<Image className="w-4 h-4" />
</button>
)}
<button
onClick={() => hasPermission('library:delete') && setDeleteConfirm({ type: 'file', id: file.id })}
onClick={() => canModify('library', 'delete', file.created_by_id) && setDeleteConfirm({ type: 'file', id: file.id })}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:delete')
canModify('library', 'delete', file.created_by_id)
? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:delete') ? 'Delete' : 'You do not have permission to delete files'}
disabled={!hasPermission('library:delete')}
title={canModify('library', 'delete', file.created_by_id) ? 'Delete' : 'You do not have permission to delete this file'}
disabled={!canModify('library', 'delete', file.created_by_id)}
>
<Trash2 className="w-4 h-4" />
</button>
+32 -8
View File
@@ -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}
</span>
)}
{currentPrintUser && (
<span className="flex items-center gap-1" title={`Started by ${currentPrintUser}`}>
<User className="w-3 h-3" />
{currentPrintUser}
</span>
)}
</div>
</>
) : (
@@ -2267,18 +2291,18 @@ function PrinterCard({
<div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
hasPermission('printers:ams_rfid')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
if (!hasPermission('printers:ams_rfid')) return;
refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
setAmsSlotMenu(null);
}}
disabled={isRefreshing || !hasPermission('printers:control')}
title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
disabled={isRefreshing || !hasPermission('printers:ams_rfid')}
title={!hasPermission('printers:ams_rfid') ? 'You do not have permission to re-read AMS RFID' : undefined}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
Re-read RFID
@@ -2456,18 +2480,18 @@ function PrinterCard({
<div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
hasPermission('printers:ams_rfid')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
if (!hasPermission('printers:ams_rfid')) return;
refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
setAmsSlotMenu(null);
}}
disabled={isHtRefreshing || !hasPermission('printers:control')}
title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
disabled={isHtRefreshing || !hasPermission('printers:ams_rfid')}
title={!hasPermission('printers:ams_rfid') ? 'You do not have permission to re-read AMS RFID' : undefined}
>
<RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
Re-read RFID
+25 -13
View File
@@ -44,6 +44,7 @@ import {
Check,
CheckSquare,
Square,
User,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -279,6 +280,7 @@ function SortableQueueItem({
isSelected = false,
onToggleSelect,
hasPermission,
canModify,
}: {
item: PrintQueueItem;
position?: number;
@@ -292,6 +294,7 @@ function SortableQueueItem({
isSelected?: boolean;
onToggleSelect?: () => void;
hasPermission: (permission: Permission) => boolean;
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
}) {
const canReorder = hasPermission('queue:reorder');
const {
@@ -427,6 +430,12 @@ function SortableQueueItem({
{formatDuration(item.print_time_seconds)}
</span>
)}
{item.created_by_username && (
<span className="flex items-center gap-1.5" title={`Added by ${item.created_by_username}`}>
<User className="w-3.5 h-3.5" />
{item.created_by_username}
</span>
)}
{isPending && !item.manual_start && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
@@ -518,8 +527,8 @@ function SortableQueueItem({
variant="ghost"
size="sm"
onClick={onEdit}
disabled={!hasPermission('queue:update')}
title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : 'Edit'}
disabled={!canModify('queue', 'update', item.created_by_id)}
title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'}
>
<Pencil className="w-4 h-4" />
</Button>
@@ -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"
>
<X className="w-4 h-4" />
@@ -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'}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -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<number | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('');
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}
>
<Trash2 className="w-4 h-4" />
Clear History
@@ -963,6 +972,7 @@ export function QueuePage() {
onStart={() => {}}
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
/>
))}
</div>
@@ -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}
>
<Pencil className="w-4 h-4" />
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}
>
<X className="w-4 h-4" />
Cancel Selected
@@ -1078,6 +1088,7 @@ export function QueuePage() {
isSelected={selectedItems.includes(item.id)}
onToggleSelect={() => handleToggleSelect(item.id)}
hasPermission={hasPermission}
canModify={canModify}
/>
))}
</div>
@@ -1132,6 +1143,7 @@ export function QueuePage() {
onStart={() => {}}
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
/>
))}
</div>
+114 -11
View File
@@ -95,6 +95,8 @@ export function SettingsPage() {
const [showEditUserModal, setShowEditUserModal] = useState(false);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [deleteUserId, setDeleteUserId] = useState<number | null>(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() {
</Button>
)}
{hasPermission('users:delete') && userItem.id !== user?.id && (
<Button size="sm" variant="ghost" onClick={() => setDeleteUserId(userItem.id)}>
<Button size="sm" variant="ghost" onClick={() => handleDeleteUserClick(userItem.id)}>
<Trash2 className="w-4 h-4" />
</Button>
)}
@@ -3892,17 +3911,101 @@ export function SettingsPage() {
{/* Delete User Confirmation Modal */}
{deleteUserId !== null && (
<ConfirmModal
title="Delete User"
message="Are you sure you want to delete this user? This action cannot be undone."
confirmText="Delete User"
variant="danger"
onConfirm={() => {
deleteUserMutation.mutate(deleteUserId);
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => {
setDeleteUserId(null);
setDeleteUserItemCounts(null);
}}
onCancel={() => setDeleteUserId(null)}
/>
>
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center gap-2 text-red-400">
<Trash2 className="w-5 h-5" />
<h3 className="text-lg font-semibold">Delete User</h3>
</div>
</CardHeader>
<CardContent className="space-y-4">
{deleteUserLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-bambu-green border-t-transparent" />
</div>
) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? (
<>
<p className="text-white">This user has created:</p>
<ul className="list-disc list-inside text-bambu-gray space-y-1">
{deleteUserItemCounts.archives > 0 && (
<li>{deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}</li>
)}
{deleteUserItemCounts.queue_items > 0 && (
<li>{deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}</li>
)}
{deleteUserItemCounts.library_files > 0 && (
<li>{deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}</li>
)}
</ul>
<p className="text-bambu-gray text-sm">What would you like to do with these items?</p>
<div className="flex flex-col gap-2">
<Button
variant="danger"
onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: true })}
disabled={deleteUserMutation.isPending}
className="justify-center"
>
Delete user AND their items
</Button>
<Button
variant="secondary"
onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}
disabled={deleteUserMutation.isPending}
className="justify-center"
>
Delete user, keep items (become ownerless)
</Button>
<Button
variant="ghost"
onClick={() => {
setDeleteUserId(null);
setDeleteUserItemCounts(null);
}}
disabled={deleteUserMutation.isPending}
className="justify-center"
>
Cancel
</Button>
</div>
</>
) : (
<>
<p className="text-white">Are you sure you want to delete this user?</p>
<p className="text-bambu-gray text-sm">This action cannot be undone.</p>
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
onClick={() => {
setDeleteUserId(null);
setDeleteUserItemCounts(null);
}}
disabled={deleteUserMutation.isPending}
>
Cancel
</Button>
<Button
variant="danger"
onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}
disabled={deleteUserMutation.isPending}
>
Delete User
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
)}
{/* Create/Edit Group Modal */}
+2 -2
View File
@@ -695,8 +695,8 @@ export function StatsPage() {
<Button
variant="secondary"
onClick={handleRecalculateCosts}
disabled={isRecalculating || !hasPermission('archives:update')}
title={!hasPermission('archives:update') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
disabled={isRecalculating || !hasPermission('archives:update_all')}
title={!hasPermission('archives:update_all') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
>
{isRecalculating ? (
<Loader2 className="w-4 h-4 animate-spin" />
+23 -9
View File
@@ -9,6 +9,8 @@ type OverlaySize = 'small' | 'medium' | 'large';
interface OverlayConfig {
size: OverlaySize;
fps: number;
showCamera: boolean;
showProgress: boolean;
showLayers: boolean;
showEta: boolean;
@@ -20,8 +22,18 @@ interface OverlayConfig {
function parseConfig(params: URLSearchParams): OverlayConfig {
const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
// Parse FPS (default 15, max 30, min 1)
const fpsParam = parseInt(params.get('fps') || '15', 10);
const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
// Parse camera toggle (default true, set camera=false to hide)
const cameraParam = params.get('camera');
const showCamera = cameraParam !== 'false' && cameraParam !== '0';
return {
size: (params.get('size') as OverlaySize) || 'medium',
fps,
showCamera,
showProgress: show.includes('progress'),
showLayers: show.includes('layers'),
showEta: show.includes('eta'),
@@ -191,18 +203,20 @@ export function StreamOverlayPage() {
const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
const progress = status.progress || 0;
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
return (
<div className="min-h-screen bg-black relative overflow-hidden">
{/* Camera feed - fullscreen background */}
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
{/* Camera feed - fullscreen background (optional) */}
{config.showCamera && (
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
)}
{/* Bambuddy logo - top right */}
<a
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DAZbTvYK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-d5ZW47G8.css">
<script type="module" crossorigin src="/assets/index-1q7Yxq-H.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh
cd backend
../venv/bin/python3 -m pytest tests/ -v
../venv/bin/python3 -m pytest tests/ -v -n 14
cd ..
+5
View File
@@ -10,6 +10,11 @@ git add .
git commit -m "Updated Wiki"
git push
cd ../bambuddy-languages
git add .
git commit -m "Updated Bambuddy Languages"
git push
cd ../spoolbuddy-website
git add .
git commit -m "Updated website"