Feature: print files directly from project view (closes #930) (#932)

* feat: print files directly from project view (closes #930)

Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
This commit is contained in:
lietschaend
2026-04-10 12:54:27 +02:00
committed by GitHub
parent b453385d22
commit f95b2acd7d
21 changed files with 566 additions and 31 deletions
+14 -1
View File
@@ -1044,14 +1044,16 @@ async def scan_external_folder(
async def list_files(
response: Response,
folder_id: int | None = None,
project_id: int | None = None,
include_root: bool = True,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
):
"""List files, optionally filtered by folder.
"""List files, optionally filtered by folder or project.
Args:
folder_id: Filter by folder ID. If None and include_root=True, returns root files.
project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).
include_root: If True and folder_id is None, returns files at root level.
If False and folder_id is None, returns all files.
"""
@@ -1059,6 +1061,10 @@ async def list_files(
if folder_id is not None:
query = query.where(LibraryFile.folder_id == folder_id)
elif project_id is not None:
# Single join instead of one query per folder (avoids N+1 pattern)
query = query.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
query = query.where(LibraryFolder.project_id == project_id)
elif include_root:
query = query.where(LibraryFile.folder_id.is_(None))
@@ -2210,6 +2216,12 @@ async def print_library_file(
if not printer_manager.is_connected(printer_id):
raise HTTPException(status_code=400, detail="Printer is not connected")
# Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
if body.project_id is not None:
project_result = await db.execute(select(Project).where(Project.id == body.project_id))
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
plate_name = body.plate_name
if not plate_name and body.plate_id is not None:
plate_name = f"Plate {body.plate_id}"
@@ -2225,6 +2237,7 @@ async def print_library_file(
printer_id=printer_id,
printer_name=printer.name,
options=body.model_dump(exclude_none=True),
project_id=body.project_id,
requested_by_user_id=None,
requested_by_username=None,
)
+8
View File
@@ -21,6 +21,7 @@ from backend.app.models.library import LibraryFile
from backend.app.models.print_batch import PrintBatch
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.user import User
from backend.app.schemas.print_queue import (
PrintBatchResponse,
@@ -484,6 +485,12 @@ async def add_to_queue(
if plate_time is not None:
cached_print_time = plate_time
# Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500
if data.project_id is not None:
project_result = await db.execute(select(Project).where(Project.id == data.project_id))
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
items = []
for i in range(quantity):
@@ -508,6 +515,7 @@ async def add_to_queue(
timelapse=data.timelapse,
use_ams=data.use_ams,
gcode_injection=data.gcode_injection,
project_id=data.project_id,
position=max_pos + 1 + i,
status="pending",
created_by_id=current_user.id if current_user else None,
+2
View File
@@ -208,6 +208,8 @@ class FilePrintRequest(BaseModel):
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Project to associate the resulting archive with
project_id: int | None = None
class FileUploadResponse(BaseModel):
+2
View File
@@ -44,6 +44,8 @@ class PrintQueueItemCreate(BaseModel):
gcode_injection: bool = False
# Batch: create multiple copies (creates a batch if > 1)
quantity: int = 1
# Project to associate the resulting archive with
project_id: int | None = None
class PrintQueueItemUpdate(BaseModel):
+4
View File
@@ -854,6 +854,7 @@ class ArchiveService:
print_data: dict | None = None,
created_by_id: int | None = None,
original_filename: str | None = None,
project_id: int | None = None,
) -> PrintArchive | None:
"""Archive a 3MF file with metadata.
@@ -864,6 +865,8 @@ class ArchiveService:
created_by_id: User ID who created this archive (optional, for user tracking)
original_filename: Original human-readable filename (optional, for library files
stored with UUID names)
project_id: Project to associate this archive with (optional, set when triggered
from the project view)
"""
# Verify printer exists if specified
if printer_id is not None:
@@ -974,6 +977,7 @@ class ArchiveService:
quantity=quantity,
extra_data=metadata,
created_by_id=created_by_id,
project_id=project_id,
)
self.db.add(archive)
@@ -54,6 +54,7 @@ class PrintDispatchJob:
options: dict[str, Any] = field(default_factory=dict)
requested_by_user_id: int | None = None
requested_by_username: str | None = None
project_id: int | None = None
@dataclass(slots=True)
@@ -160,6 +161,7 @@ class BackgroundDispatchService:
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
project_id: int | None = None,
) -> dict[str, Any]:
return await self._dispatch(
kind="print_library_file",
@@ -170,6 +172,7 @@ class BackgroundDispatchService:
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
project_id=project_id,
)
async def cancel_job(self, job_id: int) -> dict[str, Any]:
@@ -257,6 +260,7 @@ class BackgroundDispatchService:
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
project_id: int | None = None,
) -> dict[str, Any]:
async with self._lock:
has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
@@ -279,6 +283,7 @@ class BackgroundDispatchService:
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
project_id=project_id,
)
self._next_job_id += 1
self._batch_total += 1
@@ -722,6 +727,7 @@ class BackgroundDispatchService:
printer_id=job.printer_id,
source_file=file_path,
original_filename=lib_file.filename,
project_id=job.project_id,
)
if not archive:
raise RuntimeError("Failed to create archive")
+1
View File
@@ -1641,6 +1641,7 @@ class PrintScheduler:
source_file=file_path,
original_filename=filename,
created_by_id=item.created_by_id,
project_id=item.project_id,
)
if archive:
item.archive_id = archive.id