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