slicer_pipelines.py 7.7 KB

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