library_variants.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. """Variant groups — one job, several sliced files (#671 / #2570).
  2. A user with more than one printer model slices the same job once per model. The
  3. files are unrelated as far as the library is concerned: different names,
  4. different metadata, often uploaded separately after being sliced in Bambu Studio.
  5. A variant group is the user telling Bambuddy that they are interchangeable.
  6. Two features consume that statement from opposite ends:
  7. * the print queue picks the printer and needs the matching file (#671)
  8. * the File Manager's print action has the printer already and needs the same
  9. match (#2570)
  10. The group itself stores no model information. Each member's target model comes
  11. from its own ``sliced_for_model``, parsed out of the 3MF, so a group can never
  12. disagree with the files in it. A legacy file that declares no model may name one
  13. explicitly, because there is nothing else to go on.
  14. Invariants enforced here rather than in the database, because they are about
  15. meaning rather than shape:
  16. * **Two members minimum.** A group of one expresses no choice. Removing members
  17. down to one dissolves the group rather than leaving a stub that does nothing.
  18. * **One member per model.** Two files sliced for the same printer are not
  19. alternatives — the resolver would have no basis to prefer one, so an
  20. arbitrary pick would look like a bug the first time the wrong quality preset
  21. came out.
  22. * **Members must be sliced and must resolve to a model.** An unsliced .3mf can
  23. never be dispatched, so it cannot be a candidate.
  24. * **A file belongs to at most one group**, which the schema already guarantees;
  25. this layer turns the resulting overwrite into an explicit 409.
  26. Permissions follow library_tags.py: mutations need LIBRARY_UPDATE_ALL /
  27. LIBRARY_UPDATE_OWN, reads need LIBRARY_READ_ALL / LIBRARY_READ_OWN, and an
  28. ``*_OWN`` caller only ever sees or touches files they created.
  29. """
  30. from __future__ import annotations
  31. import logging
  32. from fastapi import APIRouter, Depends, HTTPException
  33. from sqlalchemy import select
  34. from sqlalchemy.ext.asyncio import AsyncSession
  35. from backend.app.core.auth import require_ownership_permission
  36. from backend.app.core.database import get_db
  37. from backend.app.core.permissions import Permission
  38. from backend.app.models.library import FileVariantGroup, LibraryFile
  39. from backend.app.models.user import User
  40. from backend.app.schemas.library import (
  41. VariantGroupCreate,
  42. VariantGroupMemberRequest,
  43. VariantGroupMemberResponse,
  44. VariantGroupResponse,
  45. VariantGroupUpdate,
  46. )
  47. from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
  48. logger = logging.getLogger(__name__)
  49. router = APIRouter(prefix="/library/variant-groups", tags=["library-variants"])
  50. # File types that can actually be sent to a printer. A source .3mf or an .stl
  51. # has no G-code and no sliced_for_model, so it is never a dispatch candidate.
  52. _PRINTABLE_TYPES = ("gcode.3mf", "gcode")
  53. def normalize_model_name(raw: str | None) -> str | None:
  54. """Normalize any spelling of a printer model to its short name.
  55. Internal codes are resolved **first**. ``normalize_printer_model`` returns
  56. unknown input unchanged rather than None, so an ``x or y`` chain in the other
  57. order never reaches the code map and leaves "O1C" as "O1C" — which then
  58. matches no printer row and leaves the job waiting forever. Running the code
  59. map first is a no-op for every non-code input.
  60. """
  61. if not raw:
  62. return None
  63. return normalize_printer_model(normalize_printer_model_id(raw) or raw) or raw
  64. def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) -> str | None:
  65. """Normalized model a file will be dispatched to, or None if unknowable.
  66. Precedence: the caller's explicit choice for this request, then the durable
  67. override stored on the file, then what the 3MF itself declares. The override
  68. exists because a file imported before Bambuddy parsed ``sliced_for_model``
  69. declares nothing, and without a way to say so it could never be grouped.
  70. It is kept separate from ``file_metadata`` so a user's assertion is never
  71. mistaken for something parsed out of the file.
  72. """
  73. raw = explicit or lib_file.variant_target_model or (lib_file.file_metadata or {}).get("sliced_for_model")
  74. return normalize_model_name(raw)
  75. async def _load_files(
  76. db: AsyncSession,
  77. file_ids: list[int],
  78. user: User | None,
  79. can_access_all: bool,
  80. ) -> dict[int, LibraryFile]:
  81. """Fetch the caller's visible, untrashed files by id."""
  82. query = LibraryFile.active().where(LibraryFile.id.in_(file_ids))
  83. if user is not None and not can_access_all:
  84. query = query.where(LibraryFile.created_by_id == user.id)
  85. rows = (await db.execute(query)).scalars().all()
  86. return {f.id: f for f in rows}
  87. def _validate_member(lib_file: LibraryFile, explicit_model: str | None) -> str:
  88. """Return the member's model, or raise the reason it cannot be one."""
  89. if lib_file.file_type not in _PRINTABLE_TYPES:
  90. raise HTTPException(
  91. 400,
  92. f"{lib_file.filename} is not a sliced file — only sliced output can be a print variant",
  93. )
  94. model = resolve_variant_model(lib_file, explicit_model)
  95. if not model:
  96. raise HTTPException(
  97. 400,
  98. f"{lib_file.filename} does not say which printer it was sliced for — set its target model explicitly",
  99. )
  100. if explicit_model:
  101. # Persist the user's answer, normalized. The group stores no model data
  102. # of its own, so without this the choice would last exactly one request
  103. # and the member would read back with no model at all.
  104. lib_file.variant_target_model = model
  105. return model
  106. async def _group_response(db: AsyncSession, group: FileVariantGroup) -> VariantGroupResponse:
  107. members = (
  108. (
  109. await db.execute(
  110. LibraryFile.active()
  111. .where(LibraryFile.variant_group_id == group.id)
  112. .order_by(LibraryFile.variant_position, LibraryFile.id)
  113. )
  114. )
  115. .scalars()
  116. .all()
  117. )
  118. return VariantGroupResponse(
  119. id=group.id,
  120. name=group.name,
  121. members=[
  122. VariantGroupMemberResponse(
  123. library_file_id=f.id,
  124. filename=f.filename,
  125. # Members were validated on the way in, but a file whose metadata
  126. # was rewritten since then should not blow up a read.
  127. target_model=resolve_variant_model(f) or "",
  128. position=f.variant_position,
  129. )
  130. for f in members
  131. ],
  132. )
  133. async def _get_group_or_404(db: AsyncSession, group_id: int) -> FileVariantGroup:
  134. group = (await db.execute(select(FileVariantGroup).where(FileVariantGroup.id == group_id))).scalar_one_or_none()
  135. if not group:
  136. raise HTTPException(404, "Variant group not found")
  137. return group
  138. async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> bool:
  139. """Delete the group when fewer than two members remain.
  140. A one-member group is not a choice, and leaving one behind would let the
  141. queue create a cross-model item with a single candidate that silently
  142. behaves like an ordinary job. Returns True when the group was dissolved.
  143. """
  144. remaining = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
  145. if len(remaining) >= 2:
  146. return False
  147. for lib_file in remaining:
  148. lib_file.variant_group_id = None
  149. lib_file.variant_position = 0
  150. await db.delete(group)
  151. return True
  152. @router.post("", response_model=VariantGroupResponse, status_code=201)
  153. @router.post("/", response_model=VariantGroupResponse, status_code=201)
  154. async def create_variant_group(
  155. payload: VariantGroupCreate,
  156. db: AsyncSession = Depends(get_db),
  157. auth_result: tuple[User | None, bool] = Depends(
  158. require_ownership_permission(
  159. Permission.LIBRARY_UPDATE_ALL,
  160. Permission.LIBRARY_UPDATE_OWN,
  161. )
  162. ),
  163. ) -> VariantGroupResponse:
  164. """Group files as variants of one job, in priority order."""
  165. user, can_update_all = auth_result
  166. file_ids = [m.library_file_id for m in payload.members]
  167. if len(set(file_ids)) != len(file_ids):
  168. raise HTTPException(400, "The same file cannot appear twice in a variant group")
  169. files = await _load_files(db, file_ids, user, can_update_all)
  170. missing = [fid for fid in file_ids if fid not in files]
  171. if missing:
  172. raise HTTPException(404, f"Library file not found: {missing[0]}")
  173. already_grouped = [files[fid].filename for fid in file_ids if files[fid].variant_group_id is not None]
  174. if already_grouped:
  175. raise HTTPException(409, f"{already_grouped[0]} already belongs to a variant group")
  176. models: dict[str, str] = {}
  177. for member in payload.members:
  178. lib_file = files[member.library_file_id]
  179. model = _validate_member(lib_file, member.target_model)
  180. if model in models:
  181. raise HTTPException(
  182. 400,
  183. f"{lib_file.filename} and {models[model]} are both sliced for {model} — "
  184. "variants must target different printers",
  185. )
  186. models[model] = lib_file.filename
  187. group = FileVariantGroup(
  188. name=payload.name or files[file_ids[0]].filename,
  189. created_by_id=user.id if user else None,
  190. )
  191. db.add(group)
  192. await db.flush()
  193. for position, fid in enumerate(file_ids):
  194. files[fid].variant_group_id = group.id
  195. files[fid].variant_position = position
  196. await db.commit()
  197. logger.info("Created variant group %s with %d members", group.id, len(file_ids))
  198. return await _group_response(db, group)
  199. @router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
  200. async def get_group_for_file(
  201. file_id: int,
  202. db: AsyncSession = Depends(get_db),
  203. auth_result: tuple[User | None, bool] = Depends(
  204. require_ownership_permission(
  205. Permission.LIBRARY_READ_ALL,
  206. Permission.LIBRARY_READ_OWN,
  207. )
  208. ),
  209. ) -> VariantGroupResponse:
  210. """The group a file belongs to.
  211. Both consumers start from a file rather than a group id: the print modal
  212. knows which file the user clicked, and the queue-create flow knows which
  213. file was selected.
  214. """
  215. user, can_read_all = auth_result
  216. files = await _load_files(db, [file_id], user, can_read_all)
  217. lib_file = files.get(file_id)
  218. if not lib_file:
  219. raise HTTPException(404, "Library file not found")
  220. if lib_file.variant_group_id is None:
  221. raise HTTPException(404, "File is not part of a variant group")
  222. return await _group_response(db, await _get_group_or_404(db, lib_file.variant_group_id))
  223. @router.get("/{group_id}", response_model=VariantGroupResponse)
  224. async def get_variant_group(
  225. group_id: int,
  226. db: AsyncSession = Depends(get_db),
  227. auth_result: tuple[User | None, bool] = Depends(
  228. require_ownership_permission(
  229. Permission.LIBRARY_READ_ALL,
  230. Permission.LIBRARY_READ_OWN,
  231. )
  232. ),
  233. ) -> VariantGroupResponse:
  234. return await _group_response(db, await _get_group_or_404(db, group_id))
  235. @router.patch("/{group_id}", response_model=VariantGroupResponse)
  236. async def update_variant_group(
  237. group_id: int,
  238. payload: VariantGroupUpdate,
  239. db: AsyncSession = Depends(get_db),
  240. auth_result: tuple[User | None, bool] = Depends(
  241. require_ownership_permission(
  242. Permission.LIBRARY_UPDATE_ALL,
  243. Permission.LIBRARY_UPDATE_OWN,
  244. )
  245. ),
  246. ) -> VariantGroupResponse:
  247. """Rename the group, re-order its members, or both.
  248. Re-ordering is how the user says which printer they would rather have when
  249. both are free, so it must be an explicit full ordering — a partial list
  250. would leave the rest in an order nobody chose.
  251. """
  252. user, can_update_all = auth_result
  253. group = await _get_group_or_404(db, group_id)
  254. if payload.name is not None:
  255. group.name = payload.name
  256. if payload.member_file_ids is not None:
  257. current = (
  258. (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
  259. )
  260. if set(payload.member_file_ids) != {f.id for f in current}:
  261. raise HTTPException(400, "member_file_ids must list exactly the group's current members")
  262. files = await _load_files(db, payload.member_file_ids, user, can_update_all)
  263. if len(files) != len(payload.member_file_ids):
  264. raise HTTPException(404, "Library file not found")
  265. for position, fid in enumerate(payload.member_file_ids):
  266. files[fid].variant_position = position
  267. await db.commit()
  268. return await _group_response(db, group)
  269. @router.post("/{group_id}/members", response_model=VariantGroupResponse)
  270. async def add_variant_group_member(
  271. payload: VariantGroupMemberRequest,
  272. group_id: int,
  273. db: AsyncSession = Depends(get_db),
  274. auth_result: tuple[User | None, bool] = Depends(
  275. require_ownership_permission(
  276. Permission.LIBRARY_UPDATE_ALL,
  277. Permission.LIBRARY_UPDATE_OWN,
  278. )
  279. ),
  280. ) -> VariantGroupResponse:
  281. """Attach another slice to an existing group.
  282. This is the common real case: the H2S version was queued last week, the H2C
  283. version was sliced today.
  284. """
  285. user, can_update_all = auth_result
  286. group = await _get_group_or_404(db, group_id)
  287. files = await _load_files(db, [payload.library_file_id], user, can_update_all)
  288. lib_file = files.get(payload.library_file_id)
  289. if not lib_file:
  290. raise HTTPException(404, "Library file not found")
  291. if lib_file.variant_group_id == group.id:
  292. raise HTTPException(409, f"{lib_file.filename} is already in this group")
  293. if lib_file.variant_group_id is not None:
  294. raise HTTPException(409, f"{lib_file.filename} already belongs to a variant group")
  295. model = _validate_member(lib_file, payload.target_model)
  296. existing = (
  297. (
  298. await db.execute(
  299. LibraryFile.active()
  300. .where(LibraryFile.variant_group_id == group.id)
  301. .order_by(LibraryFile.variant_position, LibraryFile.id)
  302. )
  303. )
  304. .scalars()
  305. .all()
  306. )
  307. for other in existing:
  308. if resolve_variant_model(other) == model:
  309. raise HTTPException(
  310. 400,
  311. f"{lib_file.filename} and {other.filename} are both sliced for {model} — "
  312. "variants must target different printers",
  313. )
  314. lib_file.variant_group_id = group.id
  315. lib_file.variant_position = len(existing)
  316. await db.commit()
  317. return await _group_response(db, group)
  318. @router.delete("/{group_id}/members/{file_id}", response_model=None, status_code=204)
  319. async def remove_variant_group_member(
  320. group_id: int,
  321. file_id: int,
  322. db: AsyncSession = Depends(get_db),
  323. auth_result: tuple[User | None, bool] = Depends(
  324. require_ownership_permission(
  325. Permission.LIBRARY_UPDATE_ALL,
  326. Permission.LIBRARY_UPDATE_OWN,
  327. )
  328. ),
  329. ) -> None:
  330. """Drop one file out of a group; the file itself is untouched."""
  331. user, can_update_all = auth_result
  332. group = await _get_group_or_404(db, group_id)
  333. files = await _load_files(db, [file_id], user, can_update_all)
  334. lib_file = files.get(file_id)
  335. if not lib_file or lib_file.variant_group_id != group.id:
  336. raise HTTPException(404, "File is not a member of this group")
  337. lib_file.variant_group_id = None
  338. lib_file.variant_position = 0
  339. await db.flush()
  340. await _dissolve_if_too_small(db, group)
  341. await db.commit()
  342. @router.delete("/{group_id}", response_model=None, status_code=204)
  343. async def delete_variant_group(
  344. group_id: int,
  345. db: AsyncSession = Depends(get_db),
  346. auth_result: tuple[User | None, bool] = Depends(
  347. require_ownership_permission(
  348. Permission.LIBRARY_UPDATE_ALL,
  349. Permission.LIBRARY_UPDATE_OWN,
  350. )
  351. ),
  352. ) -> None:
  353. """Ungroup the files. The files themselves are kept — every one of them is
  354. independently printable, which is the whole reason they were grouped."""
  355. group = await _get_group_or_404(db, group_id)
  356. members = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
  357. for lib_file in members:
  358. lib_file.variant_group_id = None
  359. lib_file.variant_position = 0
  360. await db.delete(group)
  361. await db.commit()