slicer_pipelines.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """API routes for Slicer Pipelines (#1425, PR A — definitions only).
  2. A pipeline bundles printer / process / filament(s) / bed-type picks so the
  3. SliceModal can apply them in one click. PR A surfaces only CRUD + an
  4. ``apply`` helper that returns the pipeline as the four ``PresetRef`` slots a
  5. ``SliceRequest`` expects. PR B adds single-target dispatch; PR C adds
  6. multi-copy fanout and the run dashboard.
  7. """
  8. import json
  9. import logging
  10. from fastapi import APIRouter, Depends, HTTPException
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  14. from backend.app.core.database import get_db
  15. from backend.app.core.permissions import Permission
  16. from backend.app.core.printer_scope import ensure_printers_exist
  17. from backend.app.models.slicer_pipeline import SlicerPipeline
  18. from backend.app.models.user import User
  19. from backend.app.schemas.slicer import PresetRef
  20. from backend.app.schemas.slicer_pipeline import (
  21. SlicerPipelineCreate,
  22. SlicerPipelineListResponse,
  23. SlicerPipelineResponse,
  24. SlicerPipelineUpdate,
  25. )
  26. logger = logging.getLogger(__name__)
  27. router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
  28. def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
  29. """Materialise the JSON filament list back into PresetRef objects so the
  30. response shape matches the create/update input shape exactly."""
  31. try:
  32. raw = json.loads(row.filament_presets_json) if row.filament_presets_json else []
  33. except (json.JSONDecodeError, TypeError):
  34. # Row was hand-edited or corrupted — return an empty list rather than
  35. # 500ing on a list endpoint. Edit/run paths will surface the problem.
  36. logger.warning("slicer_pipeline %d has invalid filament_presets_json", row.id)
  37. raw = []
  38. filament_presets = [PresetRef(**f) for f in raw if isinstance(f, dict)]
  39. return SlicerPipelineResponse(
  40. id=row.id,
  41. name=row.name,
  42. description=row.description,
  43. printer_preset=PresetRef(source=row.printer_preset_source, id=row.printer_preset_id),
  44. process_preset=PresetRef(source=row.process_preset_source, id=row.process_preset_id),
  45. filament_presets=filament_presets,
  46. bed_type=row.bed_type,
  47. target_kind=row.target_kind, # type: ignore[arg-type]
  48. target_printer_id=row.target_printer_id,
  49. target_model_class=row.target_model_class,
  50. fanout_strategy=row.fanout_strategy, # type: ignore[arg-type]
  51. created_by=row.created_by,
  52. created_at=row.created_at,
  53. updated_at=row.updated_at,
  54. )
  55. @router.get("/", response_model=SlicerPipelineListResponse)
  56. async def list_pipelines(
  57. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
  58. db: AsyncSession = Depends(get_db),
  59. ):
  60. """List all pipelines, newest first. Soft-deleted rows are hidden."""
  61. result = await db.execute(
  62. select(SlicerPipeline).where(SlicerPipeline.is_deleted.is_(False)).order_by(SlicerPipeline.id.desc())
  63. )
  64. rows = result.scalars().all()
  65. return SlicerPipelineListResponse(pipelines=[_to_response(r) for r in rows])
  66. @router.post("/", response_model=SlicerPipelineResponse, status_code=201)
  67. async def create_pipeline(
  68. data: SlicerPipelineCreate,
  69. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
  70. db: AsyncSession = Depends(get_db),
  71. ):
  72. """Create a new pipeline."""
  73. row = SlicerPipeline(
  74. name=data.name.strip(),
  75. description=data.description,
  76. printer_preset_source=data.printer_preset.source,
  77. printer_preset_id=data.printer_preset.id,
  78. process_preset_source=data.process_preset.source,
  79. process_preset_id=data.process_preset.id,
  80. filament_presets_json=json.dumps([f.model_dump() for f in data.filament_presets]),
  81. bed_type=data.bed_type,
  82. created_by=current_user.id if current_user else None,
  83. )
  84. db.add(row)
  85. await db.commit()
  86. await db.refresh(row)
  87. return _to_response(row)
  88. @router.get("/{pipeline_id}", response_model=SlicerPipelineResponse)
  89. async def get_pipeline(
  90. pipeline_id: int,
  91. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
  92. db: AsyncSession = Depends(get_db),
  93. ):
  94. """Read one pipeline by id."""
  95. result = await db.execute(
  96. select(SlicerPipeline).where(
  97. SlicerPipeline.id == pipeline_id,
  98. SlicerPipeline.is_deleted.is_(False),
  99. )
  100. )
  101. row = result.scalar_one_or_none()
  102. if not row:
  103. raise HTTPException(404, "Pipeline not found")
  104. return _to_response(row)
  105. @router.put("/{pipeline_id}", response_model=SlicerPipelineResponse)
  106. async def update_pipeline(
  107. pipeline_id: int,
  108. data: SlicerPipelineUpdate,
  109. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
  110. db: AsyncSession = Depends(get_db),
  111. ):
  112. """Update a pipeline. Only fields present in the payload are written."""
  113. result = await db.execute(
  114. select(SlicerPipeline).where(
  115. SlicerPipeline.id == pipeline_id,
  116. SlicerPipeline.is_deleted.is_(False),
  117. )
  118. )
  119. row = result.scalar_one_or_none()
  120. if not row:
  121. raise HTTPException(404, "Pipeline not found")
  122. if data.name is not None:
  123. row.name = data.name.strip()
  124. if data.description is not None:
  125. row.description = data.description
  126. if data.printer_preset is not None:
  127. row.printer_preset_source = data.printer_preset.source
  128. row.printer_preset_id = data.printer_preset.id
  129. if data.process_preset is not None:
  130. row.process_preset_source = data.process_preset.source
  131. row.process_preset_id = data.process_preset.id
  132. if data.filament_presets is not None:
  133. row.filament_presets_json = json.dumps([f.model_dump() for f in data.filament_presets])
  134. if data.bed_type is not None:
  135. row.bed_type = data.bed_type
  136. # PR B target binding. The schema accepts ``target_kind=specific_printer``
  137. # without ``target_printer_id`` (operator may be saving the kind first),
  138. # but a 'specific_printer' kind with a printer_id of 0 is rejected since
  139. # printer ids are always positive — guard against the JSON-coerced
  140. # empty-string case from the frontend.
  141. if data.target_kind is not None:
  142. row.target_kind = data.target_kind
  143. if data.target_printer_id is not None:
  144. # ``target_printer_id=0`` from the frontend means "clear the target"
  145. # (the <option value=""> case). Anything positive must reference an
  146. # actual printer row.
  147. if data.target_printer_id == 0:
  148. row.target_printer_id = None
  149. else:
  150. await ensure_printers_exist(db, [data.target_printer_id])
  151. row.target_printer_id = data.target_printer_id
  152. # PR C — class targeting + fanout strategy. Empty string from the frontend
  153. # also clears the class (radio toggled away).
  154. if data.target_model_class is not None:
  155. row.target_model_class = data.target_model_class or None
  156. if data.fanout_strategy is not None:
  157. row.fanout_strategy = data.fanout_strategy
  158. await db.commit()
  159. await db.refresh(row)
  160. return _to_response(row)
  161. @router.delete("/{pipeline_id}", status_code=204)
  162. async def delete_pipeline(
  163. pipeline_id: int,
  164. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
  165. db: AsyncSession = Depends(get_db),
  166. ):
  167. """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
  168. still resolve pipeline metadata)."""
  169. result = await db.execute(
  170. select(SlicerPipeline).where(
  171. SlicerPipeline.id == pipeline_id,
  172. SlicerPipeline.is_deleted.is_(False),
  173. )
  174. )
  175. row = result.scalar_one_or_none()
  176. if not row:
  177. raise HTTPException(404, "Pipeline not found")
  178. row.is_deleted = True
  179. await db.commit()