Files
maziggy a9b57ccd3c Add variant-group endpoints and cross-model queue creation (#671, #2570)
Adds /library/variant-groups for declaring that several sliced files are
the same job for different printers, and a variants payload on queue
creation that turns such a set into one queue item with a candidate per
file.

The candidate set is validated as a set: one file per printer model, each
file sliced for the model it is offered as, and at least one model with
an active printer. A cross-model item deliberately holds no file of its
own, because print_queue.library_file_id is ON DELETE CASCADE and would
destroy the whole job when a single alternative is deleted.

Fixes internal printer-model codes never being resolved on queue create
and update: normalize_printer_model returns unknown input unchanged, so
the or-chain never reached the code map and a "C13" target matched no
printer and waited forever.

Skips candidates whose file is trashed or missing. Library deletes are
soft, and SQLite runs with PRAGMA foreign_keys off, so neither case is
covered by the schema; the hard-delete paths now also drop the rows.

Adds library_files.variant_target_model so a user can say which printer
a file without slicer metadata is for, kept out of file_metadata so the
assertion is never mistaken for parsed data.
2026-08-03 11:10:11 +02:00

426 lines
16 KiB
Python

"""Variant groups — one job, several sliced files (#671 / #2570).
A user with more than one printer model slices the same job once per model. The
files are unrelated as far as the library is concerned: different names,
different metadata, often uploaded separately after being sliced in Bambu Studio.
A variant group is the user telling Bambuddy that they are interchangeable.
Two features consume that statement from opposite ends:
* the print queue picks the printer and needs the matching file (#671)
* the File Manager's print action has the printer already and needs the same
match (#2570)
The group itself stores no model information. Each member's target model comes
from its own ``sliced_for_model``, parsed out of the 3MF, so a group can never
disagree with the files in it. A legacy file that declares no model may name one
explicitly, because there is nothing else to go on.
Invariants enforced here rather than in the database, because they are about
meaning rather than shape:
* **Two members minimum.** A group of one expresses no choice. Removing members
down to one dissolves the group rather than leaving a stub that does nothing.
* **One member per model.** Two files sliced for the same printer are not
alternatives — the resolver would have no basis to prefer one, so an
arbitrary pick would look like a bug the first time the wrong quality preset
came out.
* **Members must be sliced and must resolve to a model.** An unsliced .3mf can
never be dispatched, so it cannot be a candidate.
* **A file belongs to at most one group**, which the schema already guarantees;
this layer turns the resulting overwrite into an explicit 409.
Permissions follow library_tags.py: mutations need LIBRARY_UPDATE_ALL /
LIBRARY_UPDATE_OWN, reads need LIBRARY_READ_ALL / LIBRARY_READ_OWN, and an
``*_OWN`` caller only ever sees or touches files they created.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import require_ownership_permission
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.library import FileVariantGroup, LibraryFile
from backend.app.models.user import User
from backend.app.schemas.library import (
VariantGroupCreate,
VariantGroupMemberRequest,
VariantGroupMemberResponse,
VariantGroupResponse,
VariantGroupUpdate,
)
from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/library/variant-groups", tags=["library-variants"])
# File types that can actually be sent to a printer. A source .3mf or an .stl
# has no G-code and no sliced_for_model, so it is never a dispatch candidate.
_PRINTABLE_TYPES = ("gcode.3mf", "gcode")
def normalize_model_name(raw: str | None) -> str | None:
"""Normalize any spelling of a printer model to its short name.
Internal codes are resolved **first**. ``normalize_printer_model`` returns
unknown input unchanged rather than None, so an ``x or y`` chain in the other
order never reaches the code map and leaves "O1C" as "O1C" — which then
matches no printer row and leaves the job waiting forever. Running the code
map first is a no-op for every non-code input.
"""
if not raw:
return None
return normalize_printer_model(normalize_printer_model_id(raw) or raw) or raw
def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) -> str | None:
"""Normalized model a file will be dispatched to, or None if unknowable.
Precedence: the caller's explicit choice for this request, then the durable
override stored on the file, then what the 3MF itself declares. The override
exists because a file imported before Bambuddy parsed ``sliced_for_model``
declares nothing, and without a way to say so it could never be grouped.
It is kept separate from ``file_metadata`` so a user's assertion is never
mistaken for something parsed out of the file.
"""
raw = explicit or lib_file.variant_target_model or (lib_file.file_metadata or {}).get("sliced_for_model")
return normalize_model_name(raw)
async def _load_files(
db: AsyncSession,
file_ids: list[int],
user: User | None,
can_access_all: bool,
) -> dict[int, LibraryFile]:
"""Fetch the caller's visible, untrashed files by id."""
query = LibraryFile.active().where(LibraryFile.id.in_(file_ids))
if user is not None and not can_access_all:
query = query.where(LibraryFile.created_by_id == user.id)
rows = (await db.execute(query)).scalars().all()
return {f.id: f for f in rows}
def _validate_member(lib_file: LibraryFile, explicit_model: str | None) -> str:
"""Return the member's model, or raise the reason it cannot be one."""
if lib_file.file_type not in _PRINTABLE_TYPES:
raise HTTPException(
400,
f"{lib_file.filename} is not a sliced file — only sliced output can be a print variant",
)
model = resolve_variant_model(lib_file, explicit_model)
if not model:
raise HTTPException(
400,
f"{lib_file.filename} does not say which printer it was sliced for — set its target model explicitly",
)
if explicit_model:
# Persist the user's answer, normalized. The group stores no model data
# of its own, so without this the choice would last exactly one request
# and the member would read back with no model at all.
lib_file.variant_target_model = model
return model
async def _group_response(db: AsyncSession, group: FileVariantGroup) -> VariantGroupResponse:
members = (
(
await db.execute(
LibraryFile.active()
.where(LibraryFile.variant_group_id == group.id)
.order_by(LibraryFile.variant_position, LibraryFile.id)
)
)
.scalars()
.all()
)
return VariantGroupResponse(
id=group.id,
name=group.name,
members=[
VariantGroupMemberResponse(
library_file_id=f.id,
filename=f.filename,
# Members were validated on the way in, but a file whose metadata
# was rewritten since then should not blow up a read.
target_model=resolve_variant_model(f) or "",
position=f.variant_position,
)
for f in members
],
)
async def _get_group_or_404(db: AsyncSession, group_id: int) -> FileVariantGroup:
group = (await db.execute(select(FileVariantGroup).where(FileVariantGroup.id == group_id))).scalar_one_or_none()
if not group:
raise HTTPException(404, "Variant group not found")
return group
async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> bool:
"""Delete the group when fewer than two members remain.
A one-member group is not a choice, and leaving one behind would let the
queue create a cross-model item with a single candidate that silently
behaves like an ordinary job. Returns True when the group was dissolved.
"""
remaining = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
if len(remaining) >= 2:
return False
for lib_file in remaining:
lib_file.variant_group_id = None
lib_file.variant_position = 0
await db.delete(group)
return True
@router.post("", response_model=VariantGroupResponse, status_code=201)
@router.post("/", response_model=VariantGroupResponse, status_code=201)
async def create_variant_group(
payload: VariantGroupCreate,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
) -> VariantGroupResponse:
"""Group files as variants of one job, in priority order."""
user, can_update_all = auth_result
file_ids = [m.library_file_id for m in payload.members]
if len(set(file_ids)) != len(file_ids):
raise HTTPException(400, "The same file cannot appear twice in a variant group")
files = await _load_files(db, file_ids, user, can_update_all)
missing = [fid for fid in file_ids if fid not in files]
if missing:
raise HTTPException(404, f"Library file not found: {missing[0]}")
already_grouped = [files[fid].filename for fid in file_ids if files[fid].variant_group_id is not None]
if already_grouped:
raise HTTPException(409, f"{already_grouped[0]} already belongs to a variant group")
models: dict[str, str] = {}
for member in payload.members:
lib_file = files[member.library_file_id]
model = _validate_member(lib_file, member.target_model)
if model in models:
raise HTTPException(
400,
f"{lib_file.filename} and {models[model]} are both sliced for {model} — "
"variants must target different printers",
)
models[model] = lib_file.filename
group = FileVariantGroup(
name=payload.name or files[file_ids[0]].filename,
created_by_id=user.id if user else None,
)
db.add(group)
await db.flush()
for position, fid in enumerate(file_ids):
files[fid].variant_group_id = group.id
files[fid].variant_position = position
await db.commit()
logger.info("Created variant group %s with %d members", group.id, len(file_ids))
return await _group_response(db, group)
@router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
async def get_group_for_file(
file_id: int,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_READ_ALL,
Permission.LIBRARY_READ_OWN,
)
),
) -> VariantGroupResponse:
"""The group a file belongs to.
Both consumers start from a file rather than a group id: the print modal
knows which file the user clicked, and the queue-create flow knows which
file was selected.
"""
user, can_read_all = auth_result
files = await _load_files(db, [file_id], user, can_read_all)
lib_file = files.get(file_id)
if not lib_file:
raise HTTPException(404, "Library file not found")
if lib_file.variant_group_id is None:
raise HTTPException(404, "File is not part of a variant group")
return await _group_response(db, await _get_group_or_404(db, lib_file.variant_group_id))
@router.get("/{group_id}", response_model=VariantGroupResponse)
async def get_variant_group(
group_id: int,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_READ_ALL,
Permission.LIBRARY_READ_OWN,
)
),
) -> VariantGroupResponse:
return await _group_response(db, await _get_group_or_404(db, group_id))
@router.patch("/{group_id}", response_model=VariantGroupResponse)
async def update_variant_group(
group_id: int,
payload: VariantGroupUpdate,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
) -> VariantGroupResponse:
"""Rename the group, re-order its members, or both.
Re-ordering is how the user says which printer they would rather have when
both are free, so it must be an explicit full ordering — a partial list
would leave the rest in an order nobody chose.
"""
user, can_update_all = auth_result
group = await _get_group_or_404(db, group_id)
if payload.name is not None:
group.name = payload.name
if payload.member_file_ids is not None:
current = (
(await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
)
if set(payload.member_file_ids) != {f.id for f in current}:
raise HTTPException(400, "member_file_ids must list exactly the group's current members")
files = await _load_files(db, payload.member_file_ids, user, can_update_all)
if len(files) != len(payload.member_file_ids):
raise HTTPException(404, "Library file not found")
for position, fid in enumerate(payload.member_file_ids):
files[fid].variant_position = position
await db.commit()
return await _group_response(db, group)
@router.post("/{group_id}/members", response_model=VariantGroupResponse)
async def add_variant_group_member(
payload: VariantGroupMemberRequest,
group_id: int,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
) -> VariantGroupResponse:
"""Attach another slice to an existing group.
This is the common real case: the H2S version was queued last week, the H2C
version was sliced today.
"""
user, can_update_all = auth_result
group = await _get_group_or_404(db, group_id)
files = await _load_files(db, [payload.library_file_id], user, can_update_all)
lib_file = files.get(payload.library_file_id)
if not lib_file:
raise HTTPException(404, "Library file not found")
if lib_file.variant_group_id == group.id:
raise HTTPException(409, f"{lib_file.filename} is already in this group")
if lib_file.variant_group_id is not None:
raise HTTPException(409, f"{lib_file.filename} already belongs to a variant group")
model = _validate_member(lib_file, payload.target_model)
existing = (
(
await db.execute(
LibraryFile.active()
.where(LibraryFile.variant_group_id == group.id)
.order_by(LibraryFile.variant_position, LibraryFile.id)
)
)
.scalars()
.all()
)
for other in existing:
if resolve_variant_model(other) == model:
raise HTTPException(
400,
f"{lib_file.filename} and {other.filename} are both sliced for {model} — "
"variants must target different printers",
)
lib_file.variant_group_id = group.id
lib_file.variant_position = len(existing)
await db.commit()
return await _group_response(db, group)
@router.delete("/{group_id}/members/{file_id}", response_model=None, status_code=204)
async def remove_variant_group_member(
group_id: int,
file_id: int,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
) -> None:
"""Drop one file out of a group; the file itself is untouched."""
user, can_update_all = auth_result
group = await _get_group_or_404(db, group_id)
files = await _load_files(db, [file_id], user, can_update_all)
lib_file = files.get(file_id)
if not lib_file or lib_file.variant_group_id != group.id:
raise HTTPException(404, "File is not a member of this group")
lib_file.variant_group_id = None
lib_file.variant_position = 0
await db.flush()
await _dissolve_if_too_small(db, group)
await db.commit()
@router.delete("/{group_id}", response_model=None, status_code=204)
async def delete_variant_group(
group_id: int,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
) -> None:
"""Ungroup the files. The files themselves are kept — every one of them is
independently printable, which is the whole reason they were grouped."""
group = await _get_group_or_404(db, group_id)
members = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
for lib_file in members:
lib_file.variant_group_id = None
lib_file.variant_position = 0
await db.delete(group)
await db.commit()