Browse Source

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
maziggy 3 months ago
parent
commit
81cc8412ac

+ 8 - 0
CHANGELOG.md

@@ -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 - 0
README.md

@@ -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 - 0
backend/app/api/routes/archives.py

@@ -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
backend/app/api/routes/library.py

@@ -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
backend/app/api/routes/print_queue.py

@@ -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 - 0
backend/app/api/routes/printers.py

@@ -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 - 0
backend/app/core/database.py

@@ -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 - 0
backend/app/main.py

@@ -1461,6 +1461,9 @@ async def on_print_complete(printer_id: int, data: dict):
     except Exception as e:
         logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}")
 
+    # Clear current print user tracking (Issue #206)
+    printer_manager.clear_current_print_user(printer_id)
+
     # MQTT relay - publish print complete
     try:
         printer_info = printer_manager.get_printer(printer_id)

+ 5 - 0
backend/app/models/archive.py

@@ -69,10 +69,15 @@ class PrintArchive(Base):
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
+    # User tracking (who uploaded/created this archive)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     project: Mapped["Project | None"] = relationship(back_populates="archives")
+    created_by: Mapped["User | None"] = relationship()
 
 
 from backend.app.models.printer import Printer  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
+from backend.app.models.user import User  # noqa: E402, F811

+ 5 - 0
backend/app/models/library.py

@@ -82,6 +82,9 @@ class LibraryFile(Base):
     # User notes
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # User tracking (Issue #206)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -89,7 +92,9 @@ class LibraryFile(Base):
     # Relationships
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
+from backend.app.models.user import User  # noqa: E402, F811

+ 5 - 0
backend/app/models/print_queue.py

@@ -68,14 +68,19 @@ class PrintQueueItem(Base):
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
+    # User tracking (who added this to the queue)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
     # Relationships
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
+    created_by: Mapped["User | None"] = relationship()
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

+ 4 - 0
backend/app/schemas/archive.py

@@ -89,6 +89,10 @@ class ArchiveResponse(BaseModel):
 
     created_at: datetime
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     @model_validator(mode="after")
     def compute_object_count(self) -> "ArchiveResponse":
         """Compute object_count from extra_data.printable_objects if not set."""

+ 7 - 0
backend/app/schemas/library.py

@@ -123,6 +123,10 @@ class FileResponse(BaseModel):
     duplicates: list[FileDuplicate] | None = None
     duplicate_count: int = 0
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     created_at: datetime
     updated_at: datetime
 
@@ -141,6 +145,9 @@ class FileListResponse(BaseModel):
     thumbnail_path: str | None
     print_count: int
     duplicate_count: int = 0
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
     created_at: datetime
 
     # Key metadata fields for display

+ 4 - 0
backend/app/schemas/print_queue.py

@@ -95,6 +95,10 @@ class PrintQueueItemResponse(BaseModel):
     printer_name: str | None = None
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     class Config:
         from_attributes = True
 

+ 19 - 4
backend/app/services/archive.py

@@ -806,8 +806,16 @@ class ArchiveService:
         printer_id: int | None,
         source_file: Path,
         print_data: dict | None = None,
+        created_by_id: int | None = None,
     ) -> PrintArchive | None:
-        """Archive a 3MF file with metadata."""
+        """Archive a 3MF file with metadata.
+
+        Args:
+            printer_id: ID of the printer (optional)
+            source_file: Path to the 3MF file
+            print_data: Print data from MQTT (optional)
+            created_by_id: User ID who created this archive (optional, for user tracking)
+        """
         # Verify printer exists if specified
         if printer_id is not None:
             result = await self.db.execute(select(Printer).where(Printer.id == printer_id))
@@ -915,6 +923,7 @@ class ArchiveService:
             cost=cost,
             quantity=quantity,
             extra_data=metadata,
+            created_by_id=created_by_id,
         )
 
         self.db.add(archive)
@@ -924,8 +933,12 @@ class ArchiveService:
         return archive
 
     async def get_archive(self, archive_id: int) -> PrintArchive | None:
-        """Get an archive by ID."""
-        result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        """Get an archive by ID with creator loaded."""
+        from sqlalchemy.orm import selectinload
+
+        result = await self.db.execute(
+            select(PrintArchive).options(selectinload(PrintArchive.created_by)).where(PrintArchive.id == archive_id)
+        )
         return result.scalar_one_or_none()
 
     async def update_archive_status(
@@ -997,7 +1010,9 @@ class ArchiveService:
         from sqlalchemy.orm import selectinload
 
         query = (
-            select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+            .order_by(PrintArchive.created_at.desc())
         )
 
         if printer_id:

+ 14 - 0
backend/app/services/printer_manager.py

@@ -98,11 +98,25 @@ class PrinterManager:
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
+        # Track who started the current print (Issue #206)
+        self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
         return self._printer_info.get(printer_id)
 
+    def set_current_print_user(self, printer_id: int, user_id: int, username: str):
+        """Track who started the current print (Issue #206)."""
+        self._current_print_user[printer_id] = {"user_id": user_id, "username": username}
+
+    def get_current_print_user(self, printer_id: int) -> dict | None:
+        """Get the user who started the current print (Issue #206)."""
+        return self._current_print_user.get(printer_id)
+
+    def clear_current_print_user(self, printer_id: int):
+        """Clear the current print user when print completes (Issue #206)."""
+        self._current_print_user.pop(printer_id, None)
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
         self._loop = loop

+ 68 - 0
backend/tests/integration/test_library_api.py

@@ -254,6 +254,74 @@ class TestLibraryFilesAPI:
         assert result["total_folders"] == 2
         assert result["total_files"] == 1
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file list response includes user tracking fields (Issue #206)."""
+        lib_file = await file_factory(filename="test.3mf")
+        response = await async_client.get("/api/v1/library/files?include_root=false")
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result) >= 1
+        # Find our test file
+        test_file = next((f for f in result if f["id"] == lib_file.id), None)
+        assert test_file is not None
+        # User tracking fields should be present (even if null)
+        assert "created_by_id" in test_file
+        assert "created_by_username" in test_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file detail response includes user tracking fields (Issue #206)."""
+        lib_file = await file_factory(filename="test_detail.3mf")
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
+        assert response.status_code == 200
+        result = response.json()
+        # User tracking fields should be present (even if null)
+        assert "created_by_id" in result
+        assert "created_by_username" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
+        """Verify file created with user shows username in response (Issue #206)."""
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.user import User
+
+        # Create a test user
+        user = User(username="testuploader", password_hash="fakehash", role="user")
+        db_session.add(user)
+        await db_session.flush()
+
+        # Create a file with created_by_id set
+        lib_file = LibraryFile(
+            filename="user_uploaded.3mf",
+            file_path="/test/user_uploaded.3mf",
+            file_size=2048,
+            file_type="3mf",
+            created_by_id=user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        # Verify file detail shows username
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["created_by_id"] == user.id
+        assert result["created_by_username"] == "testuploader"
+
+        # Verify file list also shows username
+        response = await async_client.get("/api/v1/library/files?include_root=false")
+        assert response.status_code == 200
+        files = response.json()
+        test_file = next((f for f in files if f["id"] == lib_file.id), None)
+        assert test_file is not None
+        assert test_file["created_by_id"] == user.id
+        assert test_file["created_by_username"] == "testuploader"
+
 
 class TestLibraryAddToQueueAPI:
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""

+ 41 - 0
backend/tests/integration/test_printers_api.py

@@ -292,6 +292,47 @@ class TestPrinterDataIntegrity:
             assert response.json()["status"] == "refresh_requested"
             mock_pm.request_status_update.assert_called_once_with(printer.id)
 
+    # ========================================================================
+    # Current print user endpoint (Issue #206)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/current-print-user")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
+        """Verify empty object returned when no user is tracked."""
+        printer = await printer_factory(name="Test Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_current_print_user.return_value = None
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
+
+            assert response.status_code == 200
+            assert response.json() == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
+        """Verify user info is returned when tracked."""
+        printer = await printer_factory(name="Test Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["user_id"] == 42
+            assert result["username"] == "testuser"
+
 
 class TestPrintControlAPI:
     """Integration tests for print control endpoints (stop, pause, resume)."""

+ 60 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -565,6 +565,66 @@ class TestPrinterManager:
             assert result["state"] is None
             mock_instance.disconnect.assert_called_once()
 
+    # ========================================================================
+    # Tests for current print user tracking (Issue #206)
+    # ========================================================================
+
+    def test_set_current_print_user(self, manager):
+        """Verify current print user can be set."""
+        manager.set_current_print_user(1, 42, "testuser")
+
+        assert 1 in manager._current_print_user
+        assert manager._current_print_user[1]["user_id"] == 42
+        assert manager._current_print_user[1]["username"] == "testuser"
+
+    def test_get_current_print_user_returns_user(self, manager):
+        """Verify get_current_print_user returns the stored user."""
+        manager.set_current_print_user(1, 42, "testuser")
+
+        result = manager.get_current_print_user(1)
+
+        assert result is not None
+        assert result["user_id"] == 42
+        assert result["username"] == "testuser"
+
+    def test_get_current_print_user_returns_none_for_unknown(self, manager):
+        """Verify get_current_print_user returns None for unknown printer."""
+        result = manager.get_current_print_user(999)
+        assert result is None
+
+    def test_clear_current_print_user(self, manager):
+        """Verify current print user can be cleared."""
+        manager.set_current_print_user(1, 42, "testuser")
+        manager.clear_current_print_user(1)
+
+        result = manager.get_current_print_user(1)
+        assert result is None
+
+    def test_clear_current_print_user_no_error_for_unknown(self, manager):
+        """Verify clearing unknown printer doesn't raise error."""
+        # Should not raise
+        manager.clear_current_print_user(999)
+
+    def test_set_current_print_user_overwrites_existing(self, manager):
+        """Verify setting user overwrites existing value."""
+        manager.set_current_print_user(1, 42, "user1")
+        manager.set_current_print_user(1, 99, "user2")
+
+        result = manager.get_current_print_user(1)
+        assert result["user_id"] == 99
+        assert result["username"] == "user2"
+
+    def test_multiple_printers_have_separate_users(self, manager):
+        """Verify each printer tracks its own user separately."""
+        manager.set_current_print_user(1, 42, "user1")
+        manager.set_current_print_user(2, 99, "user2")
+
+        result1 = manager.get_current_print_user(1)
+        result2 = manager.get_current_print_user(2)
+
+        assert result1["username"] == "user1"
+        assert result2["username"] == "user2"
+
 
 class TestPrinterStateToDict:
     """Tests for printer_state_to_dict helper function."""

+ 76 - 0
frontend/src/api/client.ts

@@ -324,6 +324,9 @@ export interface Archive {
   energy_kwh: number | null;
   energy_cost: number | null;
   created_at: string;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
 }
 
 export interface ArchiveStats {
@@ -1094,6 +1097,9 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
+  // User tracking (Issue #206)
+  created_by_id?: number | null;
+  created_by_username?: string | null;
 }
 
 export interface PrintQueueItemCreate {
@@ -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
frontend/src/pages/ArchivesPage.tsx

@@ -44,6 +44,7 @@ import {
   ChevronLeft,
   ChevronRight,
   Settings,
+  User,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
@@ -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 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -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 - 0
frontend/src/pages/PrintersPage.tsx

@@ -40,6 +40,7 @@ import {
   ScanSearch,
   CheckCircle,
   XCircle,
+  User,
 } from 'lucide-react';
 
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -1116,6 +1117,23 @@ function PrinterCard({
   });
   const queueCount = queueItems?.length || 0;
 
+  // Fetch currently printing queue item to show who started it (Issue #206)
+  const { data: printingQueueItems } = useQuery({
+    queryKey: ['queue', printer.id, 'printing'],
+    queryFn: () => api.getQueue(printer.id, 'printing'),
+    enabled: status?.state === 'RUNNING',
+  });
+
+  // Fetch reprint user info (for prints started via Reprint, not queue - Issue #206)
+  const { data: reprintUser } = useQuery({
+    queryKey: ['currentPrintUser', printer.id],
+    queryFn: () => api.getCurrentPrintUser(printer.id),
+    enabled: status?.state === 'RUNNING',
+  });
+
+  // Combine both sources: queue item user takes precedence, then reprint user
+  const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username;
+
   // Fetch last completed print for this printer
   const { data: lastPrints } = useQuery({
     queryKey: ['archives', printer.id, 'last'],
@@ -1896,6 +1914,12 @@ function PrinterCard({
                                 {status.layer_num}/{status.total_layers}
                               </span>
                             )}
+                            {currentPrintUser && (
+                              <span className="flex items-center gap-1" title={`Started by ${currentPrintUser}`}>
+                                <User className="w-3 h-3" />
+                                {currentPrintUser}
+                              </span>
+                            )}
                           </div>
                         </>
                       ) : (

+ 7 - 0
frontend/src/pages/QueuePage.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-CPqcJWwC.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Pd44WL0W.js


+ 2 - 2
static/index.html

@@ -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>

Some files were not shown because too many files changed in this diff