maintenance.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. """Maintenance tracking API routes."""
  2. import logging
  3. from datetime import datetime, timezone
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from sqlalchemy import select
  6. from sqlalchemy.exc import IntegrityError
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy.orm import selectinload
  9. from backend.app.core.auth import (
  10. RequestPrinterScope,
  11. RequirePermissionIfAuthEnabled,
  12. RequirePrinterPermissionIfAuthEnabled,
  13. )
  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 PrinterScope
  17. from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
  18. from backend.app.models.printer import Printer
  19. from backend.app.models.user import User
  20. from backend.app.schemas.maintenance import (
  21. MaintenanceHistoryResponse,
  22. MaintenanceStatus,
  23. MaintenanceTypeCreate,
  24. MaintenanceTypeResponse,
  25. MaintenanceTypeUpdate,
  26. PerformMaintenanceRequest,
  27. PrinterMaintenanceOverview,
  28. PrinterMaintenanceResponse,
  29. PrinterMaintenanceUpdate,
  30. )
  31. from backend.app.services.notification_service import notification_service
  32. from backend.app.utils.printer_models import get_rod_type
  33. logger = logging.getLogger(__name__)
  34. router = APIRouter(prefix="/maintenance", tags=["maintenance"])
  35. # Default maintenance types
  36. DEFAULT_MAINTENANCE_TYPES = [
  37. # Carbon rod models only (X1/P1)
  38. # Note: carbon rods must NOT be lubricated — they use plain bearings
  39. # and lubrication degrades print quality. Only cleaning is offered.
  40. {
  41. "name": "Clean Carbon Rods",
  42. "description": "Wipe carbon rods with a dry cloth",
  43. "default_interval_hours": 100.0,
  44. "icon": "Sparkles",
  45. },
  46. # Steel rod models only (P2S)
  47. {
  48. "name": "Lubricate Steel Rods",
  49. "description": "Apply lubricant to steel rods for smooth motion",
  50. "default_interval_hours": 50.0,
  51. "icon": "Droplet",
  52. },
  53. {
  54. "name": "Clean Steel Rods",
  55. "description": "Wipe steel rods with a dry cloth",
  56. "default_interval_hours": 100.0,
  57. "icon": "Sparkles",
  58. },
  59. # Linear rail models only (A1/H2)
  60. {
  61. "name": "Lubricate Linear Rails",
  62. "description": "Apply lubricant to linear rails for smooth motion",
  63. "default_interval_hours": 50.0,
  64. "icon": "Droplet",
  65. },
  66. {
  67. "name": "Clean Linear Rails",
  68. "description": "Wipe linear rails with a dry cloth to remove dust and debris",
  69. "default_interval_hours": 100.0,
  70. "icon": "Sparkles",
  71. },
  72. # Universal (all models)
  73. {
  74. "name": "Clean Nozzle/Hotend",
  75. "description": "Clean nozzle exterior and perform cold pull if needed",
  76. "default_interval_hours": 100.0,
  77. "icon": "Flame",
  78. },
  79. {
  80. "name": "Check Belt Tension",
  81. "description": "Verify and adjust belt tension for X/Y axes",
  82. "default_interval_hours": 200.0,
  83. "icon": "Ruler",
  84. },
  85. {
  86. "name": "Clean Build Plate",
  87. "description": "Deep clean build plate with IPA or soap",
  88. "default_interval_hours": 25.0,
  89. "icon": "Square",
  90. },
  91. {
  92. "name": "Check PTFE Tube",
  93. "description": "Inspect PTFE tube for wear or discoloration",
  94. "default_interval_hours": 500.0,
  95. "icon": "Cable",
  96. },
  97. ]
  98. # System types that only apply to printers with a specific rod/rail type.
  99. # "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
  100. # "linear_rail" = A1/H2 series. Types not listed here apply to all printers.
  101. _ROD_TYPE_REQUIREMENTS: dict[str, str] = {
  102. "Clean Carbon Rods": "carbon",
  103. "Lubricate Steel Rods": "steel_rod",
  104. "Clean Steel Rods": "steel_rod",
  105. "Lubricate Linear Rails": "linear_rail",
  106. "Clean Linear Rails": "linear_rail",
  107. }
  108. def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
  109. """Check if a system maintenance type should apply to a given printer model."""
  110. rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
  111. if rod_requirement is None:
  112. return True # Not model-specific, applies to all
  113. rod_type = get_rod_type(printer_model)
  114. if rod_type is None:
  115. # Unknown model — default to carbon rods (legacy behavior)
  116. return rod_requirement == "carbon"
  117. return rod_type == rod_requirement
  118. async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
  119. """Calculate total active hours for a printer from runtime counter plus offset.
  120. Uses the runtime_seconds counter which tracks actual machine active time
  121. (RUNNING state only — paused time is excluded since maintenance intervals
  122. measure mechanical wear, not wall-clock active time, see #1521).
  123. """
  124. # Get printer runtime and offset
  125. result = await db.execute(
  126. select(Printer.runtime_seconds, Printer.print_hours_offset).where(Printer.id == printer_id)
  127. )
  128. row = result.one_or_none()
  129. if not row:
  130. return 0.0
  131. runtime_seconds = row[0] or 0
  132. offset = row[1] or 0.0
  133. runtime_hours = runtime_seconds / 3600.0
  134. return runtime_hours + offset
  135. async def ensure_default_types(db: AsyncSession) -> None:
  136. """Ensure default maintenance types exist, remove stale/duplicate ones."""
  137. result = await db.execute(
  138. select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)
  139. )
  140. existing = result.scalars().all()
  141. default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
  142. # Remove stale system types no longer in defaults (e.g. renamed types)
  143. # and deduplicate: if concurrent requests created the same type twice,
  144. # keep only the first (lowest id) and delete the rest.
  145. seen_names: set[str] = set()
  146. for t in existing:
  147. if t.name not in default_names or t.name in seen_names:
  148. await db.delete(t)
  149. else:
  150. seen_names.add(t.name)
  151. # Create any missing default types
  152. for type_def in DEFAULT_MAINTENANCE_TYPES:
  153. if type_def["name"] not in seen_names:
  154. new_type = MaintenanceType(
  155. name=type_def["name"],
  156. description=type_def["description"],
  157. default_interval_hours=type_def["default_interval_hours"],
  158. icon=type_def["icon"],
  159. is_system=True,
  160. )
  161. db.add(new_type)
  162. await db.commit()
  163. # ============== Maintenance Types ==============
  164. @router.get("/types", response_model=list[MaintenanceTypeResponse])
  165. async def get_maintenance_types(
  166. db: AsyncSession = Depends(get_db),
  167. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
  168. ):
  169. """Get all maintenance types."""
  170. await ensure_default_types(db)
  171. result = await db.execute(
  172. select(MaintenanceType)
  173. .where(MaintenanceType.is_deleted.is_(False))
  174. .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
  175. )
  176. return result.scalars().all()
  177. @router.post("/types", response_model=MaintenanceTypeResponse)
  178. async def create_maintenance_type(
  179. data: MaintenanceTypeCreate,
  180. db: AsyncSession = Depends(get_db),
  181. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
  182. ):
  183. """Create a custom maintenance type."""
  184. new_type = MaintenanceType(
  185. name=data.name,
  186. description=data.description,
  187. default_interval_hours=data.default_interval_hours,
  188. interval_type=data.interval_type,
  189. icon=data.icon,
  190. wiki_url=data.wiki_url,
  191. is_system=False,
  192. )
  193. db.add(new_type)
  194. await db.commit()
  195. await db.refresh(new_type)
  196. return new_type
  197. @router.patch("/types/{type_id}", response_model=MaintenanceTypeResponse)
  198. async def update_maintenance_type(
  199. type_id: int,
  200. data: MaintenanceTypeUpdate,
  201. db: AsyncSession = Depends(get_db),
  202. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
  203. ):
  204. """Update a maintenance type."""
  205. result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
  206. maint_type = result.scalar_one_or_none()
  207. if not maint_type:
  208. raise HTTPException(status_code=404, detail="Maintenance type not found")
  209. update_data = data.model_dump(exclude_unset=True)
  210. for key, value in update_data.items():
  211. setattr(maint_type, key, value)
  212. await db.commit()
  213. await db.refresh(maint_type)
  214. return maint_type
  215. @router.delete("/types/{type_id}")
  216. async def delete_maintenance_type(
  217. type_id: int,
  218. db: AsyncSession = Depends(get_db),
  219. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
  220. ):
  221. """Delete a maintenance type."""
  222. result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
  223. maint_type = result.scalar_one_or_none()
  224. if not maint_type:
  225. raise HTTPException(status_code=404, detail="Maintenance type not found")
  226. if maint_type.is_system:
  227. maint_type.is_deleted = True
  228. await db.commit()
  229. return {"status": "deleted"}
  230. await db.delete(maint_type)
  231. await db.commit()
  232. return {"status": "deleted"}
  233. @router.post("/types/restore-defaults")
  234. async def restore_default_maintenance_types(
  235. db: AsyncSession = Depends(get_db),
  236. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
  237. ):
  238. """Restore deleted default maintenance types."""
  239. await ensure_default_types(db)
  240. result = await db.execute(
  241. select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True))
  242. )
  243. deleted_types = result.scalars().all()
  244. for maint_type in deleted_types:
  245. maint_type.is_deleted = False
  246. await db.commit()
  247. return {"restored": len(deleted_types)}
  248. # ============== Printer Maintenance ==============
  249. def maintenance_alert_values(item: MaintenanceStatus) -> tuple[float, float, str]:
  250. """What the MQTT relay's maintenance/alert reports: (value, threshold, unit).
  251. Print-hour items count hours since the last maintenance against the
  252. interval; time-based ones count days, and their interval_hours holds days.
  253. """
  254. if item.interval_type == "days":
  255. return (item.days_since_maintenance or 0.0, item.interval_hours, "days")
  256. return (item.hours_since_maintenance, item.interval_hours, "hours")
  257. async def _get_or_create_maintenance_item(db: AsyncSession, printer_id: int, type_id: int) -> PrinterMaintenance:
  258. """The printer's row for this type, created with defaults if missing.
  259. Insert first, in a savepoint: two reads of a new printer (two dashboards,
  260. or a dashboard and the background check) each used to create the row. The
  261. unique index now refuses the second, which takes the first's (FO-C14).
  262. """
  263. item = PrinterMaintenance(
  264. printer_id=printer_id,
  265. maintenance_type_id=type_id,
  266. enabled=True,
  267. last_performed_hours=0.0,
  268. )
  269. try:
  270. async with db.begin_nested():
  271. db.add(item)
  272. await db.flush()
  273. return item
  274. except IntegrityError:
  275. existing = await db.scalar(
  276. select(PrinterMaintenance).where(
  277. PrinterMaintenance.printer_id == printer_id,
  278. PrinterMaintenance.maintenance_type_id == type_id,
  279. )
  280. )
  281. if existing is None:
  282. raise
  283. return existing
  284. async def _get_printer_maintenance_internal(
  285. printer_id: int,
  286. db: AsyncSession,
  287. commit: bool = True,
  288. ) -> PrinterMaintenanceOverview:
  289. """Internal helper to get maintenance overview for a specific printer."""
  290. await ensure_default_types(db)
  291. # Get printer
  292. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  293. printer = result.scalar_one_or_none()
  294. if not printer:
  295. raise HTTPException(status_code=404, detail="Printer not found")
  296. total_hours = await get_printer_total_hours(db, printer_id)
  297. # Get all maintenance types
  298. result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False)))
  299. all_types = result.scalars().all()
  300. # Get printer's maintenance items
  301. result = await db.execute(
  302. select(PrinterMaintenance)
  303. .where(PrinterMaintenance.printer_id == printer_id)
  304. .options(selectinload(PrinterMaintenance.maintenance_type))
  305. )
  306. existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}
  307. maintenance_items = []
  308. due_count = 0
  309. warning_count = 0
  310. now = datetime.now(timezone.utc)
  311. for maint_type in all_types:
  312. # Skip system types that don't apply to this printer model
  313. # (e.g., "Clean Carbon Rods" for H2D which has steel rods)
  314. if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):
  315. continue
  316. item = existing_items.get(maint_type.id)
  317. default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
  318. if item:
  319. interval = item.custom_interval_hours or maint_type.default_interval_hours
  320. # Use custom interval type if set, otherwise use type's default
  321. interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
  322. enabled = item.enabled
  323. last_performed_hours = item.last_performed_hours
  324. last_performed_at = item.last_performed_at
  325. item_id = item.id
  326. else:
  327. # Only auto-create maintenance items for system types
  328. # Custom types need to be manually assigned per printer
  329. if not maint_type.is_system:
  330. continue
  331. # Create default entry for this printer/type, or take the one a
  332. # concurrent read just created.
  333. item = await _get_or_create_maintenance_item(db, printer_id, maint_type.id)
  334. interval = item.custom_interval_hours or maint_type.default_interval_hours
  335. interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
  336. enabled = item.enabled
  337. last_performed_hours = item.last_performed_hours
  338. last_performed_at = item.last_performed_at
  339. item_id = item.id
  340. # Calculate status based on interval type
  341. if interval_type == "days":
  342. # Time-based: calculate days since last performed
  343. if last_performed_at:
  344. # DB stores naive datetimes; treat as UTC for comparison
  345. if last_performed_at.tzinfo is None:
  346. last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)
  347. days_since = (now - last_performed_at).total_seconds() / 86400.0
  348. else:
  349. # Never performed - consider it due
  350. days_since = interval + 1
  351. days_until = interval - days_since
  352. is_due = days_until <= 0
  353. is_warning = days_until <= (interval * 0.1) and not is_due
  354. # For compatibility, also set hours values (but they won't be primary)
  355. hours_since = total_hours - last_performed_hours
  356. hours_until = 0 # Not applicable for time-based
  357. else:
  358. # Print-hours based (default)
  359. hours_since = total_hours - last_performed_hours
  360. hours_until = interval - hours_since
  361. is_due = hours_until <= 0
  362. is_warning = hours_until <= (interval * 0.1) and not is_due
  363. # Calculate days for reference
  364. if last_performed_at:
  365. if last_performed_at.tzinfo is None:
  366. last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)
  367. days_since = (now - last_performed_at).total_seconds() / 86400.0
  368. else:
  369. days_since = None
  370. days_until = None
  371. if enabled:
  372. if is_due:
  373. due_count += 1
  374. elif is_warning:
  375. warning_count += 1
  376. maintenance_items.append(
  377. MaintenanceStatus(
  378. id=item_id,
  379. printer_id=printer_id,
  380. printer_name=printer.name,
  381. printer_model=printer.model,
  382. maintenance_type_id=maint_type.id,
  383. maintenance_type_name=maint_type.name,
  384. maintenance_type_icon=maint_type.icon,
  385. maintenance_type_wiki_url=getattr(maint_type, "wiki_url", None),
  386. enabled=enabled,
  387. interval_hours=interval,
  388. interval_type=interval_type,
  389. current_hours=total_hours,
  390. hours_since_maintenance=hours_since,
  391. hours_until_due=hours_until,
  392. days_since_maintenance=days_since if interval_type == "days" else None,
  393. days_until_due=days_until if interval_type == "days" else None,
  394. is_due=is_due,
  395. is_warning=is_warning,
  396. last_performed_at=last_performed_at,
  397. )
  398. )
  399. if commit:
  400. await db.commit()
  401. return PrinterMaintenanceOverview(
  402. printer_id=printer_id,
  403. printer_name=printer.name,
  404. printer_model=printer.model,
  405. total_print_hours=total_hours,
  406. maintenance_items=maintenance_items,
  407. due_count=due_count,
  408. warning_count=warning_count,
  409. )
  410. @router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
  411. async def get_printer_maintenance(
  412. printer_id: int,
  413. db: AsyncSession = Depends(get_db),
  414. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
  415. ):
  416. """Get maintenance overview for a specific printer."""
  417. return await _get_printer_maintenance_internal(printer_id, db, commit=True)
  418. @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
  419. async def get_all_maintenance_overview(
  420. db: AsyncSession = Depends(get_db),
  421. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
  422. printer_scope: PrinterScope = RequestPrinterScope,
  423. ):
  424. """Get maintenance overview for all active printers."""
  425. await ensure_default_types(db)
  426. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  427. printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
  428. overviews = []
  429. for printer in printers:
  430. # Don't commit after each printer, commit once at the end
  431. overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)
  432. overviews.append(overview)
  433. # Commit any new maintenance items created
  434. await db.commit()
  435. return overviews
  436. @router.patch("/items/{item_id}", response_model=PrinterMaintenanceResponse)
  437. async def update_printer_maintenance(
  438. item_id: int,
  439. data: PrinterMaintenanceUpdate,
  440. db: AsyncSession = Depends(get_db),
  441. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
  442. printer_scope: PrinterScope = RequestPrinterScope,
  443. ):
  444. """Update a printer maintenance item (e.g., custom interval, enabled)."""
  445. result = await db.execute(
  446. select(PrinterMaintenance)
  447. .where(PrinterMaintenance.id == item_id)
  448. .options(selectinload(PrinterMaintenance.maintenance_type))
  449. )
  450. item = result.scalar_one_or_none()
  451. if not item or not printer_scope.allows(item.printer_id):
  452. raise HTTPException(status_code=404, detail="Maintenance item not found")
  453. update_data = data.model_dump(exclude_unset=True)
  454. for key, value in update_data.items():
  455. setattr(item, key, value)
  456. await db.commit()
  457. await db.refresh(item)
  458. return item
  459. @router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
  460. async def assign_maintenance_type(
  461. printer_id: int,
  462. type_id: int,
  463. db: AsyncSession = Depends(get_db),
  464. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
  465. ):
  466. """Assign a maintenance type to a specific printer (for custom types)."""
  467. # Verify printer exists
  468. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  469. printer = result.scalar_one_or_none()
  470. if not printer:
  471. raise HTTPException(status_code=404, detail="Printer not found")
  472. # Verify maintenance type exists
  473. result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
  474. maint_type = result.scalar_one_or_none()
  475. if not maint_type:
  476. raise HTTPException(status_code=404, detail="Maintenance type not found")
  477. # Check if already assigned
  478. result = await db.execute(
  479. select(PrinterMaintenance).where(
  480. PrinterMaintenance.printer_id == printer_id,
  481. PrinterMaintenance.maintenance_type_id == type_id,
  482. )
  483. )
  484. existing = result.scalar_one_or_none()
  485. if existing:
  486. raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
  487. # Create the assignment
  488. item = PrinterMaintenance(
  489. printer_id=printer_id,
  490. maintenance_type_id=type_id,
  491. enabled=True,
  492. last_performed_hours=0.0,
  493. )
  494. db.add(item)
  495. try:
  496. await db.commit()
  497. except IntegrityError:
  498. # A concurrent assign (a double click) passed the check above too; the
  499. # unique index refuses this one (FO-C14).
  500. await db.rollback()
  501. raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer") from None
  502. # Re-fetch with relationship loaded for response serialization
  503. from sqlalchemy.orm import selectinload
  504. result = await db.execute(
  505. select(PrinterMaintenance)
  506. .options(selectinload(PrinterMaintenance.maintenance_type))
  507. .where(PrinterMaintenance.id == item.id)
  508. )
  509. item = result.scalar_one()
  510. return item
  511. @router.delete("/items/{item_id}")
  512. async def remove_maintenance_item(
  513. item_id: int,
  514. db: AsyncSession = Depends(get_db),
  515. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
  516. printer_scope: PrinterScope = RequestPrinterScope,
  517. ):
  518. """Remove a maintenance item (unassign a custom type from a printer)."""
  519. result = await db.execute(
  520. select(PrinterMaintenance)
  521. .where(PrinterMaintenance.id == item_id)
  522. .options(selectinload(PrinterMaintenance.maintenance_type))
  523. )
  524. item = result.scalar_one_or_none()
  525. if not item or not printer_scope.allows(item.printer_id):
  526. raise HTTPException(status_code=404, detail="Maintenance item not found")
  527. # Only allow removing custom (non-system) types
  528. if item.maintenance_type.is_system:
  529. raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
  530. await db.delete(item)
  531. await db.commit()
  532. return {"status": "removed"}
  533. @router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
  534. async def perform_maintenance(
  535. item_id: int,
  536. data: PerformMaintenanceRequest,
  537. db: AsyncSession = Depends(get_db),
  538. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
  539. printer_scope: PrinterScope = RequestPrinterScope,
  540. ):
  541. """Mark maintenance as performed (reset the counter)."""
  542. result = await db.execute(
  543. select(PrinterMaintenance)
  544. .where(PrinterMaintenance.id == item_id)
  545. .options(selectinload(PrinterMaintenance.maintenance_type))
  546. )
  547. item = result.scalar_one_or_none()
  548. if not item or not printer_scope.allows(item.printer_id):
  549. raise HTTPException(status_code=404, detail="Maintenance item not found")
  550. # Get printer for name
  551. result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
  552. printer = result.scalar_one()
  553. # Get current hours
  554. current_hours = await get_printer_total_hours(db, item.printer_id)
  555. # Create history entry
  556. history = MaintenanceHistory(
  557. printer_maintenance_id=item.id,
  558. hours_at_maintenance=current_hours,
  559. notes=data.notes,
  560. )
  561. db.add(history)
  562. # Update item
  563. item.last_performed_at = datetime.now(timezone.utc)
  564. item.last_performed_hours = current_hours
  565. await db.commit()
  566. # MQTT relay - publish maintenance reset
  567. try:
  568. from backend.app.services.mqtt_relay import mqtt_relay
  569. await mqtt_relay.on_maintenance_reset(
  570. printer_id=item.printer_id,
  571. printer_name=printer.name,
  572. maintenance_type=item.maintenance_type.name,
  573. )
  574. except Exception:
  575. pass # Don't fail if MQTT fails
  576. # Calculate status
  577. interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
  578. # The item's own interval type first, as the status list reads it (dbcol-09).
  579. interval_type = item.custom_interval_type or getattr(item.maintenance_type, "interval_type", "hours") or "hours"
  580. hours_since = current_hours - item.last_performed_hours
  581. hours_until = interval - hours_since
  582. return MaintenanceStatus(
  583. id=item.id,
  584. printer_id=item.printer_id,
  585. printer_name=printer.name,
  586. printer_model=printer.model,
  587. maintenance_type_id=item.maintenance_type_id,
  588. maintenance_type_name=item.maintenance_type.name,
  589. maintenance_type_icon=item.maintenance_type.icon,
  590. maintenance_type_wiki_url=getattr(item.maintenance_type, "wiki_url", None),
  591. enabled=item.enabled,
  592. interval_hours=interval,
  593. interval_type=interval_type,
  594. current_hours=current_hours,
  595. hours_since_maintenance=hours_since,
  596. hours_until_due=hours_until if interval_type == "hours" else 0,
  597. days_since_maintenance=0 if interval_type == "days" else None,
  598. days_until_due=interval if interval_type == "days" else None,
  599. is_due=False,
  600. is_warning=False,
  601. last_performed_at=item.last_performed_at,
  602. )
  603. @router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
  604. async def get_maintenance_history(
  605. item_id: int,
  606. db: AsyncSession = Depends(get_db),
  607. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
  608. printer_scope: PrinterScope = RequestPrinterScope,
  609. ):
  610. """Get maintenance history for a specific item."""
  611. item_printer_id = (
  612. await db.execute(select(PrinterMaintenance.printer_id).where(PrinterMaintenance.id == item_id))
  613. ).scalar_one_or_none()
  614. if not printer_scope.allows(item_printer_id):
  615. raise HTTPException(status_code=404, detail="Maintenance item not found")
  616. result = await db.execute(
  617. select(MaintenanceHistory)
  618. .where(MaintenanceHistory.printer_maintenance_id == item_id)
  619. .order_by(MaintenanceHistory.performed_at.desc())
  620. )
  621. return result.scalars().all()
  622. @router.get("/summary")
  623. async def get_maintenance_summary(
  624. db: AsyncSession = Depends(get_db),
  625. _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
  626. printer_scope: PrinterScope = RequestPrinterScope,
  627. ):
  628. """Get a summary of maintenance status across all printers."""
  629. await ensure_default_types(db)
  630. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  631. printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
  632. total_due = 0
  633. total_warning = 0
  634. printers_with_issues = []
  635. for printer in printers:
  636. overview = await get_printer_maintenance(printer.id, db)
  637. total_due += overview.due_count
  638. total_warning += overview.warning_count
  639. if overview.due_count > 0 or overview.warning_count > 0:
  640. printers_with_issues.append(
  641. {
  642. "printer_id": printer.id,
  643. "printer_name": printer.name,
  644. "due_count": overview.due_count,
  645. "warning_count": overview.warning_count,
  646. }
  647. )
  648. return {
  649. "total_due": total_due,
  650. "total_warning": total_warning,
  651. "printers_with_issues": printers_with_issues,
  652. }
  653. @router.patch("/printers/{printer_id}/hours")
  654. async def set_printer_hours(
  655. printer_id: int,
  656. total_hours: float,
  657. db: AsyncSession = Depends(get_db),
  658. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
  659. ):
  660. """Set the total print hours for a printer (adjusts offset to match).
  661. The offset is calculated as: offset = total_hours - runtime_hours
  662. Where runtime_hours comes from the runtime_seconds counter that tracks
  663. actual machine active time (RUNNING state only — paused time excluded, #1521).
  664. """
  665. # Get printer
  666. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  667. printer = result.scalar_one_or_none()
  668. if not printer:
  669. raise HTTPException(status_code=404, detail="Printer not found")
  670. # Get current runtime hours
  671. runtime_hours = (printer.runtime_seconds or 0) / 3600.0
  672. # Calculate needed offset
  673. printer.print_hours_offset = max(0, total_hours - runtime_hours)
  674. await db.commit()
  675. # Check for maintenance items that need attention and send notification
  676. try:
  677. await ensure_default_types(db)
  678. overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
  679. items_needing_attention = [
  680. {
  681. "name": item.maintenance_type_name,
  682. "is_due": item.is_due,
  683. "is_warning": item.is_warning,
  684. }
  685. for item in overview.maintenance_items
  686. if item.enabled and (item.is_due or item.is_warning)
  687. ]
  688. if items_needing_attention:
  689. await notification_service.on_maintenance_due(printer_id, printer.name, items_needing_attention, db)
  690. logger.info(
  691. f"Sent maintenance notification for printer {printer_id}: "
  692. f"{len(items_needing_attention)} items need attention"
  693. )
  694. except Exception as e:
  695. logger.warning("Failed to send maintenance notification: %s", e)
  696. return {
  697. "printer_id": printer_id,
  698. "total_hours": total_hours,
  699. "runtime_hours": runtime_hours,
  700. "offset_hours": printer.print_hours_offset,
  701. }