scheduled_dryings.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """Scheduled (delayed) manual AMS drying runs (#2638)."""
  2. from fastapi import APIRouter, Depends, HTTPException
  3. from sqlalchemy import select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
  6. from backend.app.core.database import get_db
  7. from backend.app.core.permissions import Permission
  8. from backend.app.core.printer_scope import PrinterScope
  9. from backend.app.models.printer import Printer
  10. from backend.app.models.scheduled_drying import ScheduledDrying
  11. from backend.app.models.user import User
  12. from backend.app.schemas.scheduled_drying import ScheduledDryingCreate, ScheduledDryingResponse
  13. from backend.app.services import drying_preflight
  14. from backend.app.services.printer_manager import printer_manager
  15. from backend.app.utils.local_time import utcnow_naive
  16. router = APIRouter(prefix="/scheduled-dryings", tags=["scheduled-dryings"])
  17. ACTIVE_STATUSES = ("pending", "running")
  18. # Failed rows are listed too. A run can only fail at dispatch (the firmware
  19. # turns out too old, say), which is exactly the case a schedule-time check on
  20. # an offline printer cannot catch, so without this the run just disappears and
  21. # only the backend log knows why. The client dismisses the row to clear it.
  22. LISTED_STATUSES = (*ACTIVE_STATUSES, "failed")
  23. @router.post("", response_model=ScheduledDryingResponse)
  24. async def create_scheduled_drying(
  25. payload: ScheduledDryingCreate,
  26. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  27. printer_scope: PrinterScope = RequestPrinterScope,
  28. db: AsyncSession = Depends(get_db),
  29. ):
  30. printer_scope.ensure(payload.printer_id)
  31. result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
  32. printer = result.scalar_one_or_none()
  33. if not printer:
  34. raise HTTPException(404, "Printer not found")
  35. # Fail fast in the UI rather than hours later with nobody watching. An
  36. # offline printer is still schedulable: only its model is judged here.
  37. state = printer_manager.get_status(payload.printer_id)
  38. unsupported = drying_preflight.check_drying_supported(
  39. printer.model,
  40. state.firmware_version if state else None,
  41. require_firmware=state is not None,
  42. )
  43. if unsupported:
  44. raise HTTPException(400, unsupported)
  45. if payload.start_after is not None and payload.start_after <= utcnow_naive():
  46. raise HTTPException(400, "start_after must be in the future")
  47. row = ScheduledDrying(
  48. printer_id=payload.printer_id,
  49. ams_id=payload.ams_id,
  50. temp=payload.temp,
  51. duration_hours=payload.duration_hours,
  52. filament=payload.filament,
  53. rotate_tray=payload.rotate_tray,
  54. start_after=payload.start_after,
  55. created_by_id=user.id if user else None,
  56. )
  57. db.add(row)
  58. await db.commit()
  59. await db.refresh(row)
  60. return row
  61. @router.get("", response_model=list[ScheduledDryingResponse])
  62. async def list_scheduled_dryings(
  63. printer_id: int | None = None,
  64. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  65. printer_scope: PrinterScope = RequestPrinterScope,
  66. db: AsyncSession = Depends(get_db),
  67. ):
  68. query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
  69. if (clause := printer_scope.where_strict(ScheduledDrying.printer_id)) is not None:
  70. query = query.where(clause)
  71. if printer_id is not None:
  72. query = query.where(ScheduledDrying.printer_id == printer_id)
  73. result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
  74. return list(result.scalars().all())
  75. @router.delete("/{scheduled_drying_id}")
  76. async def cancel_scheduled_drying(
  77. scheduled_drying_id: int,
  78. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  79. printer_scope: PrinterScope = RequestPrinterScope,
  80. db: AsyncSession = Depends(get_db),
  81. ):
  82. result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
  83. row = result.scalar_one_or_none()
  84. if not row or not printer_scope.allows(row.printer_id):
  85. raise HTTPException(404, "Scheduled drying not found")
  86. if row.status == "failed":
  87. # Terminal and now acknowledged; drop it so it stops being listed.
  88. await db.delete(row)
  89. await db.commit()
  90. return {"status": "dismissed", "id": scheduled_drying_id}
  91. if row.status not in ACTIVE_STATUSES:
  92. raise HTTPException(400, "Only pending, running or failed dryings can be cancelled")
  93. if row.status == "running":
  94. # Best effort; cancellation proceeds even if the printer is offline.
  95. printer_manager.send_drying_command(row.printer_id, row.ams_id, 0, 0, mode=0)
  96. row.status = "cancelled"
  97. row.completed_at = utcnow_naive()
  98. await db.commit()
  99. return {"status": "cancelled", "id": row.id}