mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
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.
426 lines
16 KiB
Python
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()
|