Add user tracking for prints, archives, library files, and queue (Issue #206)

Track and display who performs key actions in Bambuddy:
- Archives: who uploaded each archive file
- Library: who uploaded each file in File Manager
- Queue: who added each print job to the queue
- Printers: who started the current print (reprint tracking)

Backend changes:
- Add created_by_id column to print_archives, library_files, print_queue tables
- Add database migrations for new columns (auto-run on startup)
- Update archive, library, and queue routes to capture current user
- Add current-print-user endpoint for printer reprint tracking
- Track reprint user in PrinterManager in-memory state
- Fix file uploads not sending auth headers (FormData requires explicit headers)

Frontend changes:
- Display username on archive cards, library files, queue items
- Show "Started by" on printer cards during active prints
- Add auth headers to all 12 FormData upload functions
- Update TypeScript types for user tracking fields

Tests:
- Add unit tests for PrinterManager user tracking methods (7 tests)
- Add integration tests for current-print-user endpoint (3 tests)
- Add integration tests for library file user tracking (3 tests)

Works when authentication is enabled; gracefully hidden when disabled.

Closes #206
This commit is contained in:
maziggy
2026-02-01 10:26:11 +01:00
parent 63c707b1df
commit 81cc8412ac
27 changed files with 572 additions and 123 deletions
+8
View File
@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
## [0.1.7b] - Not released
### Enhancements
- **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
- **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)
@@ -26,6 +33,7 @@ All notable changes to Bambuddy will be documented in this file.
- 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
+1
View File
@@ -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>
+15
View File
@@ -8,10 +8,12 @@ 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
from backend.app.core.config import settings
from backend.app.core.database import get_db
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 +98,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
@@ -2018,6 +2023,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 +2041,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 +2058,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 +2080,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 +2433,7 @@ async def reprint_archive(
printer_id: int,
body: ReprintRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
):
"""Send an archived 3MF file to a printer and start printing."""
from backend.app.main import register_expected_print
@@ -2555,6 +2565,11 @@ async def reprint_archive(
if not started:
raise HTTPException(500, "Failed to start print")
# Track who started this print (Issue #206)
if current_user:
printer_manager.set_current_print_user(printer_id, current_user.id, current_user.username)
logger.info(f"Reprint started by user: {current_user.username}")
return {
"status": "printing",
"printer_id": printer_id,
+15 -2
View File
@@ -13,13 +13,16 @@ 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
from backend.app.core.config import settings as app_settings
from backend.app.core.database import get_db
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,
@@ -587,7 +590,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)
@@ -634,6 +637,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,
@@ -651,6 +656,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:
@@ -756,6 +762,7 @@ async def upload_file(
file_hash=file_hash,
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()
@@ -785,6 +792,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.
@@ -992,6 +1000,7 @@ async def extract_zip_file(
file_hash=file_hash,
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()
@@ -1749,7 +1758,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:
@@ -1806,6 +1817,8 @@ 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,
)
+12 -3
View File
@@ -12,12 +12,14 @@ 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
from backend.app.core.config import settings
from backend.app.core.database import get_db
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 +142,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 +179,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 +202,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 +305,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")
@@ -407,6 +415,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)
)
@@ -469,7 +478,7 @@ 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)
@@ -624,7 +633,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)
+21
View File
@@ -444,6 +444,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,
+24
View File
@@ -1014,6 +1014,30 @@ 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:
+3
View File
@@ -1461,6 +1461,9 @@ async def on_print_complete(printer_id: int, data: dict):
except Exception as e:
logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}")
# Clear current print user tracking (Issue #206)
printer_manager.clear_current_print_user(printer_id)
# MQTT relay - publish print complete
try:
printer_info = printer_manager.get_printer(printer_id)
+5
View File
@@ -69,10 +69,15 @@ class PrintArchive(Base):
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# User tracking (who uploaded/created this archive)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Relationships
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
project: Mapped["Project | None"] = relationship(back_populates="archives")
created_by: Mapped["User | None"] = relationship()
from backend.app.models.printer import Printer # noqa: E402, F811
from backend.app.models.project import Project # noqa: E402, F811
from backend.app.models.user import User # noqa: E402, F811
+5
View File
@@ -82,6 +82,9 @@ class LibraryFile(Base):
# User notes
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# User tracking (Issue #206)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -89,7 +92,9 @@ class LibraryFile(Base):
# Relationships
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
project: Mapped["Project | None"] = relationship()
created_by: Mapped["User | None"] = relationship()
from backend.app.models.archive import PrintArchive # noqa: E402, F811
from backend.app.models.project import Project # noqa: E402, F811
from backend.app.models.user import User # noqa: E402, F811
+5
View File
@@ -68,14 +68,19 @@ class PrintQueueItem(Base):
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# User tracking (who added this to the queue)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Relationships
printer: Mapped["Printer"] = relationship()
archive: Mapped["PrintArchive | None"] = relationship()
library_file: Mapped["LibraryFile | None"] = relationship()
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
created_by: Mapped["User | None"] = relationship()
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.library import LibraryFile # noqa: E402
from backend.app.models.printer import Printer # noqa: E402
from backend.app.models.project import Project # noqa: E402
from backend.app.models.user import User # noqa: E402
+4
View File
@@ -89,6 +89,10 @@ class ArchiveResponse(BaseModel):
created_at: datetime
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
@model_validator(mode="after")
def compute_object_count(self) -> "ArchiveResponse":
"""Compute object_count from extra_data.printable_objects if not set."""
+7
View File
@@ -123,6 +123,10 @@ class FileResponse(BaseModel):
duplicates: list[FileDuplicate] | None = None
duplicate_count: int = 0
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
created_at: datetime
updated_at: datetime
@@ -141,6 +145,9 @@ class FileListResponse(BaseModel):
thumbnail_path: str | None
print_count: int
duplicate_count: int = 0
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
created_at: datetime
# Key metadata fields for display
+4
View File
@@ -95,6 +95,10 @@ class PrintQueueItemResponse(BaseModel):
printer_name: str | None = None
print_time_seconds: int | None = None # Estimated print time from archive or library file
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
class Config:
from_attributes = True
+19 -4
View File
@@ -806,8 +806,16 @@ class ArchiveService:
printer_id: int | None,
source_file: Path,
print_data: dict | None = None,
created_by_id: int | None = None,
) -> PrintArchive | None:
"""Archive a 3MF file with metadata."""
"""Archive a 3MF file with metadata.
Args:
printer_id: ID of the printer (optional)
source_file: Path to the 3MF file
print_data: Print data from MQTT (optional)
created_by_id: User ID who created this archive (optional, for user tracking)
"""
# Verify printer exists if specified
if printer_id is not None:
result = await self.db.execute(select(Printer).where(Printer.id == printer_id))
@@ -915,6 +923,7 @@ class ArchiveService:
cost=cost,
quantity=quantity,
extra_data=metadata,
created_by_id=created_by_id,
)
self.db.add(archive)
@@ -924,8 +933,12 @@ class ArchiveService:
return archive
async def get_archive(self, archive_id: int) -> PrintArchive | None:
"""Get an archive by ID."""
result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
"""Get an archive by ID with creator loaded."""
from sqlalchemy.orm import selectinload
result = await self.db.execute(
select(PrintArchive).options(selectinload(PrintArchive.created_by)).where(PrintArchive.id == archive_id)
)
return result.scalar_one_or_none()
async def update_archive_status(
@@ -997,7 +1010,9 @@ class ArchiveService:
from sqlalchemy.orm import selectinload
query = (
select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
select(PrintArchive)
.options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
.order_by(PrintArchive.created_at.desc())
)
if printer_id:
+14
View File
@@ -98,11 +98,25 @@ class PrinterManager:
self._on_ams_change: Callable[[int, list], None] | None = None
self._on_layer_change: Callable[[int, int], None] | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# Track who started the current print (Issue #206)
self._current_print_user: dict[int, dict] = {} # {printer_id: {"user_id": int, "username": str}}
def get_printer(self, printer_id: int) -> PrinterInfo | None:
"""Get printer info by ID."""
return self._printer_info.get(printer_id)
def set_current_print_user(self, printer_id: int, user_id: int, username: str):
"""Track who started the current print (Issue #206)."""
self._current_print_user[printer_id] = {"user_id": user_id, "username": username}
def get_current_print_user(self, printer_id: int) -> dict | None:
"""Get the user who started the current print (Issue #206)."""
return self._current_print_user.get(printer_id)
def clear_current_print_user(self, printer_id: int):
"""Clear the current print user when print completes (Issue #206)."""
self._current_print_user.pop(printer_id, None)
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop for async callbacks."""
self._loop = loop
@@ -254,6 +254,74 @@ class TestLibraryFilesAPI:
assert result["total_folders"] == 2
assert result["total_files"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
"""Verify file list response includes user tracking fields (Issue #206)."""
lib_file = await file_factory(filename="test.3mf")
response = await async_client.get("/api/v1/library/files?include_root=false")
assert response.status_code == 200
result = response.json()
assert len(result) >= 1
# Find our test file
test_file = next((f for f in result if f["id"] == lib_file.id), None)
assert test_file is not None
# User tracking fields should be present (even if null)
assert "created_by_id" in test_file
assert "created_by_username" in test_file
@pytest.mark.asyncio
@pytest.mark.integration
async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
"""Verify file detail response includes user tracking fields (Issue #206)."""
lib_file = await file_factory(filename="test_detail.3mf")
response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
assert response.status_code == 200
result = response.json()
# User tracking fields should be present (even if null)
assert "created_by_id" in result
assert "created_by_username" in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
"""Verify file created with user shows username in response (Issue #206)."""
from backend.app.models.library import LibraryFile
from backend.app.models.user import User
# Create a test user
user = User(username="testuploader", password_hash="fakehash", role="user")
db_session.add(user)
await db_session.flush()
# Create a file with created_by_id set
lib_file = LibraryFile(
filename="user_uploaded.3mf",
file_path="/test/user_uploaded.3mf",
file_size=2048,
file_type="3mf",
created_by_id=user.id,
)
db_session.add(lib_file)
await db_session.commit()
await db_session.refresh(lib_file)
# Verify file detail shows username
response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
assert response.status_code == 200
result = response.json()
assert result["created_by_id"] == user.id
assert result["created_by_username"] == "testuploader"
# Verify file list also shows username
response = await async_client.get("/api/v1/library/files?include_root=false")
assert response.status_code == 200
files = response.json()
test_file = next((f for f in files if f["id"] == lib_file.id), None)
assert test_file is not None
assert test_file["created_by_id"] == user.id
assert test_file["created_by_username"] == "testuploader"
class TestLibraryAddToQueueAPI:
"""Integration tests for /api/v1/library/files/add-to-queue endpoint."""
@@ -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)."""
@@ -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."""
+76
View File
@@ -324,6 +324,9 @@ export interface Archive {
energy_kwh: number | null;
energy_cost: number | null;
created_at: string;
// User tracking (Issue #206)
created_by_id: number | null;
created_by_username: string | null;
}
export interface ArchiveStats {
@@ -1094,6 +1097,9 @@ export interface PrintQueueItem {
library_file_thumbnail?: string | null;
printer_name?: string | null;
print_time_seconds?: number | null; // Estimated print time from archive or library file
// User tracking (Issue #206)
created_by_id?: number | null;
created_by_username?: string | null;
}
export interface PrintQueueItemCreate {
@@ -1975,6 +1981,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 +2233,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 +2288,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 +2309,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 +2336,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 +2361,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 +2496,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 +2517,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) {
@@ -2525,8 +2570,13 @@ export const api = {
const formData = new FormData();
formData.append('file', file);
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<{
@@ -3027,8 +3077,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) {
@@ -3087,8 +3142,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) {
@@ -3205,8 +3265,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) {
@@ -3229,8 +3294,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) {
@@ -3530,6 +3600,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;
}
@@ -3543,6 +3616,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;
+18 -3
View File
@@ -44,6 +44,7 @@ import {
ChevronLeft,
ChevronRight,
Settings,
User,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
@@ -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 */}
@@ -1731,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)}
+5
View File
@@ -937,6 +937,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 */}
+24
View File
@@ -40,6 +40,7 @@ import {
ScanSearch,
CheckCircle,
XCircle,
User,
} from 'lucide-react';
// Custom Skip Objects icon - arrow jumping over boxes
@@ -1116,6 +1117,23 @@ function PrinterCard({
});
const queueCount = queueItems?.length || 0;
// Fetch currently printing queue item to show who started it (Issue #206)
const { data: printingQueueItems } = useQuery({
queryKey: ['queue', printer.id, 'printing'],
queryFn: () => api.getQueue(printer.id, 'printing'),
enabled: status?.state === 'RUNNING',
});
// Fetch reprint user info (for prints started via Reprint, not queue - Issue #206)
const { data: reprintUser } = useQuery({
queryKey: ['currentPrintUser', printer.id],
queryFn: () => api.getCurrentPrintUser(printer.id),
enabled: status?.state === 'RUNNING',
});
// Combine both sources: queue item user takes precedence, then reprint user
const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username;
// Fetch last completed print for this printer
const { data: lastPrints } = useQuery({
queryKey: ['archives', printer.id, 'last'],
@@ -1896,6 +1914,12 @@ function PrinterCard({
{status.layer_num}/{status.total_layers}
</span>
)}
{currentPrintUser && (
<span className="flex items-center gap-1" title={`Started by ${currentPrintUser}`}>
<User className="w-3 h-3" />
{currentPrintUser}
</span>
)}
</div>
</>
) : (
+7
View File
@@ -44,6 +44,7 @@ import {
Check,
CheckSquare,
Square,
User,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -427,6 +428,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" />
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-B5u_lQK-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BMToB_z5.css">
<script type="module" crossorigin src="/assets/index-Pd44WL0W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
</head>
<body>
<div id="root"></div>