"""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()