mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user