mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
+90
-2033
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ..
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user