maintenance.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. """Maintenance tracking API routes."""
  2. import logging
  3. from datetime import datetime
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from sqlalchemy import func, select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy.orm import selectinload
  8. from backend.app.core.database import get_db
  9. from backend.app.models.archive import PrintArchive
  10. from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
  11. from backend.app.models.printer import Printer
  12. from backend.app.schemas.maintenance import (
  13. MaintenanceHistoryResponse,
  14. MaintenanceStatus,
  15. MaintenanceTypeCreate,
  16. MaintenanceTypeResponse,
  17. MaintenanceTypeUpdate,
  18. PerformMaintenanceRequest,
  19. PrinterMaintenanceOverview,
  20. PrinterMaintenanceResponse,
  21. PrinterMaintenanceUpdate,
  22. )
  23. from backend.app.services.notification_service import notification_service
  24. logger = logging.getLogger(__name__)
  25. router = APIRouter(prefix="/maintenance", tags=["maintenance"])
  26. # Default maintenance types
  27. DEFAULT_MAINTENANCE_TYPES = [
  28. {
  29. "name": "Lubricate Linear Rails",
  30. "description": "Apply lubricant to linear rails and rods for smooth motion",
  31. "default_interval_hours": 50.0,
  32. "icon": "Droplet",
  33. },
  34. {
  35. "name": "Clean Nozzle/Hotend",
  36. "description": "Clean nozzle exterior and perform cold pull if needed",
  37. "default_interval_hours": 100.0,
  38. "icon": "Flame",
  39. },
  40. {
  41. "name": "Check Belt Tension",
  42. "description": "Verify and adjust belt tension for X/Y axes",
  43. "default_interval_hours": 200.0,
  44. "icon": "Ruler",
  45. },
  46. {
  47. "name": "Clean Carbon Rods",
  48. "description": "Wipe carbon rods with a dry cloth",
  49. "default_interval_hours": 100.0,
  50. "icon": "Sparkles",
  51. },
  52. {
  53. "name": "Clean Build Plate",
  54. "description": "Deep clean build plate with IPA or soap",
  55. "default_interval_hours": 25.0,
  56. "icon": "Square",
  57. },
  58. {
  59. "name": "Check PTFE Tube",
  60. "description": "Inspect PTFE tube for wear or discoloration",
  61. "default_interval_hours": 500.0,
  62. "icon": "Cable",
  63. },
  64. ]
  65. async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
  66. """Calculate total print hours for a printer from archives plus offset.
  67. Includes ALL prints (completed, failed, cancelled) since the printer
  68. components are being used regardless of print outcome.
  69. """
  70. # Get archive hours (all prints, not just completed)
  71. result = await db.execute(
  72. select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id)
  73. )
  74. total_seconds = result.scalar() or 0
  75. archive_hours = total_seconds / 3600.0
  76. # Get printer offset
  77. result = await db.execute(select(Printer.print_hours_offset).where(Printer.id == printer_id))
  78. offset = result.scalar() or 0.0
  79. return archive_hours + offset
  80. async def ensure_default_types(db: AsyncSession) -> None:
  81. """Ensure default maintenance types exist."""
  82. result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True)))
  83. existing = result.scalars().all()
  84. existing_names = {t.name for t in existing}
  85. for type_def in DEFAULT_MAINTENANCE_TYPES:
  86. if type_def["name"] not in existing_names:
  87. new_type = MaintenanceType(
  88. name=type_def["name"],
  89. description=type_def["description"],
  90. default_interval_hours=type_def["default_interval_hours"],
  91. icon=type_def["icon"],
  92. is_system=True,
  93. )
  94. db.add(new_type)
  95. await db.commit()
  96. # ============== Maintenance Types ==============
  97. @router.get("/types", response_model=list[MaintenanceTypeResponse])
  98. async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
  99. """Get all maintenance types."""
  100. await ensure_default_types(db)
  101. result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
  102. return result.scalars().all()
  103. @router.post("/types", response_model=MaintenanceTypeResponse)
  104. async def create_maintenance_type(
  105. data: MaintenanceTypeCreate,
  106. db: AsyncSession = Depends(get_db),
  107. ):
  108. """Create a custom maintenance type."""
  109. new_type = MaintenanceType(
  110. name=data.name,
  111. description=data.description,
  112. default_interval_hours=data.default_interval_hours,
  113. interval_type=data.interval_type,
  114. icon=data.icon,
  115. is_system=False,
  116. )
  117. db.add(new_type)
  118. await db.commit()
  119. await db.refresh(new_type)
  120. return new_type
  121. @router.patch("/types/{type_id}", response_model=MaintenanceTypeResponse)
  122. async def update_maintenance_type(
  123. type_id: int,
  124. data: MaintenanceTypeUpdate,
  125. db: AsyncSession = Depends(get_db),
  126. ):
  127. """Update a maintenance type."""
  128. result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
  129. maint_type = result.scalar_one_or_none()
  130. if not maint_type:
  131. raise HTTPException(status_code=404, detail="Maintenance type not found")
  132. update_data = data.model_dump(exclude_unset=True)
  133. for key, value in update_data.items():
  134. setattr(maint_type, key, value)
  135. await db.commit()
  136. await db.refresh(maint_type)
  137. return maint_type
  138. @router.delete("/types/{type_id}")
  139. async def delete_maintenance_type(
  140. type_id: int,
  141. db: AsyncSession = Depends(get_db),
  142. ):
  143. """Delete a custom maintenance type."""
  144. result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
  145. maint_type = result.scalar_one_or_none()
  146. if not maint_type:
  147. raise HTTPException(status_code=404, detail="Maintenance type not found")
  148. if maint_type.is_system:
  149. raise HTTPException(status_code=400, detail="Cannot delete system maintenance type")
  150. await db.delete(maint_type)
  151. await db.commit()
  152. return {"status": "deleted"}
  153. # ============== Printer Maintenance ==============
  154. async def _get_printer_maintenance_internal(
  155. printer_id: int,
  156. db: AsyncSession,
  157. commit: bool = True,
  158. ) -> PrinterMaintenanceOverview:
  159. """Internal helper to get maintenance overview for a specific printer."""
  160. await ensure_default_types(db)
  161. # Get printer
  162. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  163. printer = result.scalar_one_or_none()
  164. if not printer:
  165. raise HTTPException(status_code=404, detail="Printer not found")
  166. total_hours = await get_printer_total_hours(db, printer_id)
  167. # Get all maintenance types
  168. result = await db.execute(select(MaintenanceType))
  169. all_types = result.scalars().all()
  170. # Get printer's maintenance items
  171. result = await db.execute(
  172. select(PrinterMaintenance)
  173. .where(PrinterMaintenance.printer_id == printer_id)
  174. .options(selectinload(PrinterMaintenance.maintenance_type))
  175. )
  176. existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}
  177. maintenance_items = []
  178. due_count = 0
  179. warning_count = 0
  180. now = datetime.utcnow()
  181. for maint_type in all_types:
  182. item = existing_items.get(maint_type.id)
  183. default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
  184. if item:
  185. interval = item.custom_interval_hours or maint_type.default_interval_hours
  186. # Use custom interval type if set, otherwise use type's default
  187. interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
  188. enabled = item.enabled
  189. last_performed_hours = item.last_performed_hours
  190. last_performed_at = item.last_performed_at
  191. item_id = item.id
  192. else:
  193. # Only auto-create maintenance items for system types
  194. # Custom types need to be manually assigned per printer
  195. if not maint_type.is_system:
  196. continue
  197. # Create default entry for this printer/type
  198. item = PrinterMaintenance(
  199. printer_id=printer_id,
  200. maintenance_type_id=maint_type.id,
  201. enabled=True,
  202. last_performed_hours=0.0,
  203. )
  204. db.add(item)
  205. await db.flush()
  206. interval = maint_type.default_interval_hours
  207. interval_type = default_interval_type
  208. enabled = True
  209. last_performed_hours = 0.0
  210. last_performed_at = None
  211. item_id = item.id
  212. # Calculate status based on interval type
  213. if interval_type == "days":
  214. # Time-based: calculate days since last performed
  215. if last_performed_at:
  216. days_since = (now - last_performed_at).total_seconds() / 86400.0
  217. else:
  218. # Never performed - consider it due
  219. days_since = interval + 1
  220. days_until = interval - days_since
  221. is_due = days_until <= 0
  222. is_warning = days_until <= (interval * 0.1) and not is_due
  223. # For compatibility, also set hours values (but they won't be primary)
  224. hours_since = total_hours - last_performed_hours
  225. hours_until = 0 # Not applicable for time-based
  226. else:
  227. # Print-hours based (default)
  228. hours_since = total_hours - last_performed_hours
  229. hours_until = interval - hours_since
  230. is_due = hours_until <= 0
  231. is_warning = hours_until <= (interval * 0.1) and not is_due
  232. # Calculate days for reference
  233. if last_performed_at:
  234. days_since = (now - last_performed_at).total_seconds() / 86400.0
  235. else:
  236. days_since = None
  237. days_until = None
  238. if enabled:
  239. if is_due:
  240. due_count += 1
  241. elif is_warning:
  242. warning_count += 1
  243. maintenance_items.append(
  244. MaintenanceStatus(
  245. id=item_id,
  246. printer_id=printer_id,
  247. printer_name=printer.name,
  248. maintenance_type_id=maint_type.id,
  249. maintenance_type_name=maint_type.name,
  250. maintenance_type_icon=maint_type.icon,
  251. enabled=enabled,
  252. interval_hours=interval,
  253. interval_type=interval_type,
  254. current_hours=total_hours,
  255. hours_since_maintenance=hours_since,
  256. hours_until_due=hours_until,
  257. days_since_maintenance=days_since if interval_type == "days" else None,
  258. days_until_due=days_until if interval_type == "days" else None,
  259. is_due=is_due,
  260. is_warning=is_warning,
  261. last_performed_at=last_performed_at,
  262. )
  263. )
  264. if commit:
  265. await db.commit()
  266. return PrinterMaintenanceOverview(
  267. printer_id=printer_id,
  268. printer_name=printer.name,
  269. total_print_hours=total_hours,
  270. maintenance_items=maintenance_items,
  271. due_count=due_count,
  272. warning_count=warning_count,
  273. )
  274. @router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
  275. async def get_printer_maintenance(
  276. printer_id: int,
  277. db: AsyncSession = Depends(get_db),
  278. ):
  279. """Get maintenance overview for a specific printer."""
  280. return await _get_printer_maintenance_internal(printer_id, db, commit=True)
  281. @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
  282. async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
  283. """Get maintenance overview for all active printers."""
  284. await ensure_default_types(db)
  285. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  286. printers = result.scalars().all()
  287. overviews = []
  288. for printer in printers:
  289. # Don't commit after each printer, commit once at the end
  290. overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)
  291. overviews.append(overview)
  292. # Commit any new maintenance items created
  293. await db.commit()
  294. return overviews
  295. @router.patch("/items/{item_id}", response_model=PrinterMaintenanceResponse)
  296. async def update_printer_maintenance(
  297. item_id: int,
  298. data: PrinterMaintenanceUpdate,
  299. db: AsyncSession = Depends(get_db),
  300. ):
  301. """Update a printer maintenance item (e.g., custom interval, enabled)."""
  302. result = await db.execute(
  303. select(PrinterMaintenance)
  304. .where(PrinterMaintenance.id == item_id)
  305. .options(selectinload(PrinterMaintenance.maintenance_type))
  306. )
  307. item = result.scalar_one_or_none()
  308. if not item:
  309. raise HTTPException(status_code=404, detail="Maintenance item not found")
  310. update_data = data.model_dump(exclude_unset=True)
  311. for key, value in update_data.items():
  312. setattr(item, key, value)
  313. await db.commit()
  314. await db.refresh(item)
  315. return item
  316. @router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
  317. async def assign_maintenance_type(
  318. printer_id: int,
  319. type_id: int,
  320. db: AsyncSession = Depends(get_db),
  321. ):
  322. """Assign a maintenance type to a specific printer (for custom types)."""
  323. # Verify printer exists
  324. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  325. printer = result.scalar_one_or_none()
  326. if not printer:
  327. raise HTTPException(status_code=404, detail="Printer not found")
  328. # Verify maintenance type exists
  329. result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
  330. maint_type = result.scalar_one_or_none()
  331. if not maint_type:
  332. raise HTTPException(status_code=404, detail="Maintenance type not found")
  333. # Check if already assigned
  334. result = await db.execute(
  335. select(PrinterMaintenance).where(
  336. PrinterMaintenance.printer_id == printer_id,
  337. PrinterMaintenance.maintenance_type_id == type_id,
  338. )
  339. )
  340. existing = result.scalar_one_or_none()
  341. if existing:
  342. raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
  343. # Create the assignment
  344. item = PrinterMaintenance(
  345. printer_id=printer_id,
  346. maintenance_type_id=type_id,
  347. enabled=True,
  348. last_performed_hours=0.0,
  349. )
  350. db.add(item)
  351. await db.commit()
  352. await db.refresh(item)
  353. return item
  354. @router.delete("/items/{item_id}")
  355. async def remove_maintenance_item(
  356. item_id: int,
  357. db: AsyncSession = Depends(get_db),
  358. ):
  359. """Remove a maintenance item (unassign a custom type from a printer)."""
  360. result = await db.execute(
  361. select(PrinterMaintenance)
  362. .where(PrinterMaintenance.id == item_id)
  363. .options(selectinload(PrinterMaintenance.maintenance_type))
  364. )
  365. item = result.scalar_one_or_none()
  366. if not item:
  367. raise HTTPException(status_code=404, detail="Maintenance item not found")
  368. # Only allow removing custom (non-system) types
  369. if item.maintenance_type.is_system:
  370. raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
  371. await db.delete(item)
  372. await db.commit()
  373. return {"status": "removed"}
  374. @router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
  375. async def perform_maintenance(
  376. item_id: int,
  377. data: PerformMaintenanceRequest,
  378. db: AsyncSession = Depends(get_db),
  379. ):
  380. """Mark maintenance as performed (reset the counter)."""
  381. result = await db.execute(
  382. select(PrinterMaintenance)
  383. .where(PrinterMaintenance.id == item_id)
  384. .options(selectinload(PrinterMaintenance.maintenance_type))
  385. )
  386. item = result.scalar_one_or_none()
  387. if not item:
  388. raise HTTPException(status_code=404, detail="Maintenance item not found")
  389. # Get printer for name
  390. result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
  391. printer = result.scalar_one()
  392. # Get current hours
  393. current_hours = await get_printer_total_hours(db, item.printer_id)
  394. # Create history entry
  395. history = MaintenanceHistory(
  396. printer_maintenance_id=item.id,
  397. hours_at_maintenance=current_hours,
  398. notes=data.notes,
  399. )
  400. db.add(history)
  401. # Update item
  402. item.last_performed_at = datetime.utcnow()
  403. item.last_performed_hours = current_hours
  404. await db.commit()
  405. # Calculate status
  406. interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
  407. interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"
  408. hours_since = current_hours - item.last_performed_hours
  409. hours_until = interval - hours_since
  410. return MaintenanceStatus(
  411. id=item.id,
  412. printer_id=item.printer_id,
  413. printer_name=printer.name,
  414. maintenance_type_id=item.maintenance_type_id,
  415. maintenance_type_name=item.maintenance_type.name,
  416. maintenance_type_icon=item.maintenance_type.icon,
  417. enabled=item.enabled,
  418. interval_hours=interval,
  419. interval_type=interval_type,
  420. current_hours=current_hours,
  421. hours_since_maintenance=hours_since,
  422. hours_until_due=hours_until if interval_type == "hours" else 0,
  423. days_since_maintenance=0 if interval_type == "days" else None,
  424. days_until_due=interval if interval_type == "days" else None,
  425. is_due=False,
  426. is_warning=False,
  427. last_performed_at=item.last_performed_at,
  428. )
  429. @router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
  430. async def get_maintenance_history(
  431. item_id: int,
  432. db: AsyncSession = Depends(get_db),
  433. ):
  434. """Get maintenance history for a specific item."""
  435. result = await db.execute(
  436. select(MaintenanceHistory)
  437. .where(MaintenanceHistory.printer_maintenance_id == item_id)
  438. .order_by(MaintenanceHistory.performed_at.desc())
  439. )
  440. return result.scalars().all()
  441. @router.get("/summary")
  442. async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
  443. """Get a summary of maintenance status across all printers."""
  444. await ensure_default_types(db)
  445. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  446. printers = result.scalars().all()
  447. total_due = 0
  448. total_warning = 0
  449. printers_with_issues = []
  450. for printer in printers:
  451. overview = await get_printer_maintenance(printer.id, db)
  452. total_due += overview.due_count
  453. total_warning += overview.warning_count
  454. if overview.due_count > 0 or overview.warning_count > 0:
  455. printers_with_issues.append(
  456. {
  457. "printer_id": printer.id,
  458. "printer_name": printer.name,
  459. "due_count": overview.due_count,
  460. "warning_count": overview.warning_count,
  461. }
  462. )
  463. return {
  464. "total_due": total_due,
  465. "total_warning": total_warning,
  466. "printers_with_issues": printers_with_issues,
  467. }
  468. @router.patch("/printers/{printer_id}/hours")
  469. async def set_printer_hours(
  470. printer_id: int,
  471. total_hours: float,
  472. db: AsyncSession = Depends(get_db),
  473. ):
  474. """Set the total print hours for a printer (adjusts offset to match)."""
  475. # Get printer
  476. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  477. printer = result.scalar_one_or_none()
  478. if not printer:
  479. raise HTTPException(status_code=404, detail="Printer not found")
  480. # Get current archive hours (all prints, not just completed)
  481. # Must match get_printer_total_hours() which includes all prints
  482. result = await db.execute(
  483. select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id)
  484. )
  485. total_seconds = result.scalar() or 0
  486. archive_hours = total_seconds / 3600.0
  487. # Calculate needed offset
  488. printer.print_hours_offset = max(0, total_hours - archive_hours)
  489. await db.commit()
  490. # Check for maintenance items that need attention and send notification
  491. try:
  492. await ensure_default_types(db)
  493. overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
  494. items_needing_attention = [
  495. {
  496. "name": item.maintenance_type_name,
  497. "is_due": item.is_due,
  498. "is_warning": item.is_warning,
  499. }
  500. for item in overview.maintenance_items
  501. if item.enabled and (item.is_due or item.is_warning)
  502. ]
  503. if items_needing_attention:
  504. await notification_service.on_maintenance_due(printer_id, printer.name, items_needing_attention, db)
  505. logger.info(
  506. f"Sent maintenance notification for printer {printer_id}: "
  507. f"{len(items_needing_attention)} items need attention"
  508. )
  509. except Exception as e:
  510. logger.warning(f"Failed to send maintenance notification: {e}")
  511. return {
  512. "printer_id": printer_id,
  513. "total_hours": total_hours,
  514. "archive_hours": archive_hours,
  515. "offset_hours": printer.print_hours_offset,
  516. }