| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763 |
- """Maintenance tracking API routes."""
- import logging
- from datetime import datetime, timezone
- from fastapi import APIRouter, Depends, HTTPException
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import selectinload
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
- from backend.app.models.printer import Printer
- from backend.app.models.user import User
- from backend.app.schemas.maintenance import (
- MaintenanceHistoryResponse,
- MaintenanceStatus,
- MaintenanceTypeCreate,
- MaintenanceTypeResponse,
- MaintenanceTypeUpdate,
- PerformMaintenanceRequest,
- PrinterMaintenanceOverview,
- PrinterMaintenanceResponse,
- PrinterMaintenanceUpdate,
- )
- from backend.app.services.notification_service import notification_service
- from backend.app.utils.printer_models import get_rod_type
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/maintenance", tags=["maintenance"])
- # Default maintenance types
- DEFAULT_MAINTENANCE_TYPES = [
- # Carbon rod models only (X1/P1)
- # Note: carbon rods must NOT be lubricated — they use plain bearings
- # and lubrication degrades print quality. Only cleaning is offered.
- {
- "name": "Clean Carbon Rods",
- "description": "Wipe carbon rods with a dry cloth",
- "default_interval_hours": 100.0,
- "icon": "Sparkles",
- },
- # Steel rod models only (P2S)
- {
- "name": "Lubricate Steel Rods",
- "description": "Apply lubricant to steel rods for smooth motion",
- "default_interval_hours": 50.0,
- "icon": "Droplet",
- },
- {
- "name": "Clean Steel Rods",
- "description": "Wipe steel rods with a dry cloth",
- "default_interval_hours": 100.0,
- "icon": "Sparkles",
- },
- # Linear rail models only (A1/H2)
- {
- "name": "Lubricate Linear Rails",
- "description": "Apply lubricant to linear rails for smooth motion",
- "default_interval_hours": 50.0,
- "icon": "Droplet",
- },
- {
- "name": "Clean Linear Rails",
- "description": "Wipe linear rails with a dry cloth to remove dust and debris",
- "default_interval_hours": 100.0,
- "icon": "Sparkles",
- },
- # Universal (all models)
- {
- "name": "Clean Nozzle/Hotend",
- "description": "Clean nozzle exterior and perform cold pull if needed",
- "default_interval_hours": 100.0,
- "icon": "Flame",
- },
- {
- "name": "Check Belt Tension",
- "description": "Verify and adjust belt tension for X/Y axes",
- "default_interval_hours": 200.0,
- "icon": "Ruler",
- },
- {
- "name": "Clean Build Plate",
- "description": "Deep clean build plate with IPA or soap",
- "default_interval_hours": 25.0,
- "icon": "Square",
- },
- {
- "name": "Check PTFE Tube",
- "description": "Inspect PTFE tube for wear or discoloration",
- "default_interval_hours": 500.0,
- "icon": "Cable",
- },
- ]
- # System types that only apply to printers with a specific rod/rail type.
- # "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
- # "linear_rail" = A1/H2 series. Types not listed here apply to all printers.
- _ROD_TYPE_REQUIREMENTS: dict[str, str] = {
- "Clean Carbon Rods": "carbon",
- "Lubricate Steel Rods": "steel_rod",
- "Clean Steel Rods": "steel_rod",
- "Lubricate Linear Rails": "linear_rail",
- "Clean Linear Rails": "linear_rail",
- }
- def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
- """Check if a system maintenance type should apply to a given printer model."""
- rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
- if rod_requirement is None:
- return True # Not model-specific, applies to all
- rod_type = get_rod_type(printer_model)
- if rod_type is None:
- # Unknown model — default to carbon rods (legacy behavior)
- return rod_requirement == "carbon"
- return rod_type == rod_requirement
- async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
- """Calculate total active hours for a printer from runtime counter plus offset.
- Uses the runtime_seconds counter which tracks actual machine active time
- (RUNNING state only — paused time is excluded since maintenance intervals
- measure mechanical wear, not wall-clock active time, see #1521).
- """
- # Get printer runtime and offset
- result = await db.execute(
- select(Printer.runtime_seconds, Printer.print_hours_offset).where(Printer.id == printer_id)
- )
- row = result.one_or_none()
- if not row:
- return 0.0
- runtime_seconds = row[0] or 0
- offset = row[1] or 0.0
- runtime_hours = runtime_seconds / 3600.0
- return runtime_hours + offset
- async def ensure_default_types(db: AsyncSession) -> None:
- """Ensure default maintenance types exist, remove stale/duplicate ones."""
- result = await db.execute(
- select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)
- )
- existing = result.scalars().all()
- default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
- # Remove stale system types no longer in defaults (e.g. renamed types)
- # and deduplicate: if concurrent requests created the same type twice,
- # keep only the first (lowest id) and delete the rest.
- seen_names: set[str] = set()
- for t in existing:
- if t.name not in default_names or t.name in seen_names:
- await db.delete(t)
- else:
- seen_names.add(t.name)
- # Create any missing default types
- for type_def in DEFAULT_MAINTENANCE_TYPES:
- if type_def["name"] not in seen_names:
- new_type = MaintenanceType(
- name=type_def["name"],
- description=type_def["description"],
- default_interval_hours=type_def["default_interval_hours"],
- icon=type_def["icon"],
- is_system=True,
- )
- db.add(new_type)
- await db.commit()
- # ============== Maintenance Types ==============
- @router.get("/types", response_model=list[MaintenanceTypeResponse])
- async def get_maintenance_types(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
- ):
- """Get all maintenance types."""
- await ensure_default_types(db)
- result = await db.execute(
- select(MaintenanceType)
- .where(MaintenanceType.is_deleted.is_(False))
- .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
- )
- return result.scalars().all()
- @router.post("/types", response_model=MaintenanceTypeResponse)
- async def create_maintenance_type(
- data: MaintenanceTypeCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
- ):
- """Create a custom maintenance type."""
- new_type = MaintenanceType(
- name=data.name,
- description=data.description,
- default_interval_hours=data.default_interval_hours,
- interval_type=data.interval_type,
- icon=data.icon,
- is_system=False,
- )
- db.add(new_type)
- await db.commit()
- await db.refresh(new_type)
- return new_type
- @router.patch("/types/{type_id}", response_model=MaintenanceTypeResponse)
- async def update_maintenance_type(
- type_id: int,
- data: MaintenanceTypeUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
- ):
- """Update a maintenance type."""
- result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
- maint_type = result.scalar_one_or_none()
- if not maint_type:
- raise HTTPException(status_code=404, detail="Maintenance type not found")
- update_data = data.model_dump(exclude_unset=True)
- for key, value in update_data.items():
- setattr(maint_type, key, value)
- await db.commit()
- await db.refresh(maint_type)
- return maint_type
- @router.delete("/types/{type_id}")
- async def delete_maintenance_type(
- type_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
- ):
- """Delete a maintenance type."""
- result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
- maint_type = result.scalar_one_or_none()
- if not maint_type:
- raise HTTPException(status_code=404, detail="Maintenance type not found")
- if maint_type.is_system:
- maint_type.is_deleted = True
- await db.commit()
- return {"status": "deleted"}
- await db.delete(maint_type)
- await db.commit()
- return {"status": "deleted"}
- @router.post("/types/restore-defaults")
- async def restore_default_maintenance_types(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
- ):
- """Restore deleted default maintenance types."""
- await ensure_default_types(db)
- result = await db.execute(
- select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True))
- )
- deleted_types = result.scalars().all()
- for maint_type in deleted_types:
- maint_type.is_deleted = False
- await db.commit()
- return {"restored": len(deleted_types)}
- # ============== Printer Maintenance ==============
- async def _get_printer_maintenance_internal(
- printer_id: int,
- db: AsyncSession,
- commit: bool = True,
- ) -> PrinterMaintenanceOverview:
- """Internal helper to get maintenance overview for a specific printer."""
- await ensure_default_types(db)
- # Get printer
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(status_code=404, detail="Printer not found")
- total_hours = await get_printer_total_hours(db, printer_id)
- # Get all maintenance types
- result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False)))
- all_types = result.scalars().all()
- # Get printer's maintenance items
- result = await db.execute(
- select(PrinterMaintenance)
- .where(PrinterMaintenance.printer_id == printer_id)
- .options(selectinload(PrinterMaintenance.maintenance_type))
- )
- existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}
- maintenance_items = []
- due_count = 0
- warning_count = 0
- now = datetime.now(timezone.utc)
- for maint_type in all_types:
- # Skip system types that don't apply to this printer model
- # (e.g., "Clean Carbon Rods" for H2D which has steel rods)
- if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):
- continue
- item = existing_items.get(maint_type.id)
- default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
- if item:
- interval = item.custom_interval_hours or maint_type.default_interval_hours
- # Use custom interval type if set, otherwise use type's default
- interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
- enabled = item.enabled
- last_performed_hours = item.last_performed_hours
- last_performed_at = item.last_performed_at
- item_id = item.id
- else:
- # Only auto-create maintenance items for system types
- # Custom types need to be manually assigned per printer
- if not maint_type.is_system:
- continue
- # Create default entry for this printer/type
- item = PrinterMaintenance(
- printer_id=printer_id,
- maintenance_type_id=maint_type.id,
- enabled=True,
- last_performed_hours=0.0,
- )
- db.add(item)
- await db.flush()
- interval = maint_type.default_interval_hours
- interval_type = default_interval_type
- enabled = True
- last_performed_hours = 0.0
- last_performed_at = None
- item_id = item.id
- # Calculate status based on interval type
- if interval_type == "days":
- # Time-based: calculate days since last performed
- if last_performed_at:
- # DB stores naive datetimes; treat as UTC for comparison
- if last_performed_at.tzinfo is None:
- last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)
- days_since = (now - last_performed_at).total_seconds() / 86400.0
- else:
- # Never performed - consider it due
- days_since = interval + 1
- days_until = interval - days_since
- is_due = days_until <= 0
- is_warning = days_until <= (interval * 0.1) and not is_due
- # For compatibility, also set hours values (but they won't be primary)
- hours_since = total_hours - last_performed_hours
- hours_until = 0 # Not applicable for time-based
- else:
- # Print-hours based (default)
- hours_since = total_hours - last_performed_hours
- hours_until = interval - hours_since
- is_due = hours_until <= 0
- is_warning = hours_until <= (interval * 0.1) and not is_due
- # Calculate days for reference
- if last_performed_at:
- if last_performed_at.tzinfo is None:
- last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)
- days_since = (now - last_performed_at).total_seconds() / 86400.0
- else:
- days_since = None
- days_until = None
- if enabled:
- if is_due:
- due_count += 1
- elif is_warning:
- warning_count += 1
- maintenance_items.append(
- MaintenanceStatus(
- id=item_id,
- printer_id=printer_id,
- printer_name=printer.name,
- printer_model=printer.model,
- maintenance_type_id=maint_type.id,
- maintenance_type_name=maint_type.name,
- maintenance_type_icon=maint_type.icon,
- maintenance_type_wiki_url=getattr(maint_type, "wiki_url", None),
- enabled=enabled,
- interval_hours=interval,
- interval_type=interval_type,
- current_hours=total_hours,
- hours_since_maintenance=hours_since,
- hours_until_due=hours_until,
- days_since_maintenance=days_since if interval_type == "days" else None,
- days_until_due=days_until if interval_type == "days" else None,
- is_due=is_due,
- is_warning=is_warning,
- last_performed_at=last_performed_at,
- )
- )
- if commit:
- await db.commit()
- return PrinterMaintenanceOverview(
- printer_id=printer_id,
- printer_name=printer.name,
- printer_model=printer.model,
- total_print_hours=total_hours,
- maintenance_items=maintenance_items,
- due_count=due_count,
- warning_count=warning_count,
- )
- @router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
- async def get_printer_maintenance(
- printer_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
- ):
- """Get maintenance overview for a specific printer."""
- return await _get_printer_maintenance_internal(printer_id, db, commit=True)
- @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
- async def get_all_maintenance_overview(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
- ):
- """Get maintenance overview for all active printers."""
- await ensure_default_types(db)
- result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
- printers = result.scalars().all()
- overviews = []
- for printer in printers:
- # Don't commit after each printer, commit once at the end
- overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)
- overviews.append(overview)
- # Commit any new maintenance items created
- await db.commit()
- return overviews
- @router.patch("/items/{item_id}", response_model=PrinterMaintenanceResponse)
- async def update_printer_maintenance(
- item_id: int,
- data: PrinterMaintenanceUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
- ):
- """Update a printer maintenance item (e.g., custom interval, enabled)."""
- result = await db.execute(
- select(PrinterMaintenance)
- .where(PrinterMaintenance.id == item_id)
- .options(selectinload(PrinterMaintenance.maintenance_type))
- )
- item = result.scalar_one_or_none()
- if not item:
- raise HTTPException(status_code=404, detail="Maintenance item not found")
- update_data = data.model_dump(exclude_unset=True)
- for key, value in update_data.items():
- setattr(item, key, value)
- await db.commit()
- await db.refresh(item)
- return item
- @router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
- async def assign_maintenance_type(
- printer_id: int,
- type_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
- ):
- """Assign a maintenance type to a specific printer (for custom types)."""
- # Verify printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(status_code=404, detail="Printer not found")
- # Verify maintenance type exists
- result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
- maint_type = result.scalar_one_or_none()
- if not maint_type:
- raise HTTPException(status_code=404, detail="Maintenance type not found")
- # Check if already assigned
- result = await db.execute(
- select(PrinterMaintenance).where(
- PrinterMaintenance.printer_id == printer_id,
- PrinterMaintenance.maintenance_type_id == type_id,
- )
- )
- existing = result.scalar_one_or_none()
- if existing:
- raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
- # Create the assignment
- item = PrinterMaintenance(
- printer_id=printer_id,
- maintenance_type_id=type_id,
- enabled=True,
- last_performed_hours=0.0,
- )
- db.add(item)
- await db.commit()
- # Re-fetch with relationship loaded for response serialization
- from sqlalchemy.orm import selectinload
- result = await db.execute(
- select(PrinterMaintenance)
- .options(selectinload(PrinterMaintenance.maintenance_type))
- .where(PrinterMaintenance.id == item.id)
- )
- item = result.scalar_one()
- return item
- @router.delete("/items/{item_id}")
- async def remove_maintenance_item(
- item_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
- ):
- """Remove a maintenance item (unassign a custom type from a printer)."""
- result = await db.execute(
- select(PrinterMaintenance)
- .where(PrinterMaintenance.id == item_id)
- .options(selectinload(PrinterMaintenance.maintenance_type))
- )
- item = result.scalar_one_or_none()
- if not item:
- raise HTTPException(status_code=404, detail="Maintenance item not found")
- # Only allow removing custom (non-system) types
- if item.maintenance_type.is_system:
- raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
- await db.delete(item)
- await db.commit()
- return {"status": "removed"}
- @router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
- async def perform_maintenance(
- item_id: int,
- data: PerformMaintenanceRequest,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
- ):
- """Mark maintenance as performed (reset the counter)."""
- result = await db.execute(
- select(PrinterMaintenance)
- .where(PrinterMaintenance.id == item_id)
- .options(selectinload(PrinterMaintenance.maintenance_type))
- )
- item = result.scalar_one_or_none()
- if not item:
- raise HTTPException(status_code=404, detail="Maintenance item not found")
- # Get printer for name
- result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
- printer = result.scalar_one()
- # Get current hours
- current_hours = await get_printer_total_hours(db, item.printer_id)
- # Create history entry
- history = MaintenanceHistory(
- printer_maintenance_id=item.id,
- hours_at_maintenance=current_hours,
- notes=data.notes,
- )
- db.add(history)
- # Update item
- item.last_performed_at = datetime.now(timezone.utc)
- item.last_performed_hours = current_hours
- await db.commit()
- # MQTT relay - publish maintenance reset
- try:
- from backend.app.services.mqtt_relay import mqtt_relay
- await mqtt_relay.on_maintenance_reset(
- printer_id=item.printer_id,
- printer_name=printer.name,
- maintenance_type=item.maintenance_type.name,
- )
- except Exception:
- pass # Don't fail if MQTT fails
- # Calculate status
- interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
- interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"
- hours_since = current_hours - item.last_performed_hours
- hours_until = interval - hours_since
- return MaintenanceStatus(
- id=item.id,
- printer_id=item.printer_id,
- printer_name=printer.name,
- printer_model=printer.model,
- maintenance_type_id=item.maintenance_type_id,
- maintenance_type_name=item.maintenance_type.name,
- maintenance_type_icon=item.maintenance_type.icon,
- maintenance_type_wiki_url=getattr(item.maintenance_type, "wiki_url", None),
- enabled=item.enabled,
- interval_hours=interval,
- interval_type=interval_type,
- current_hours=current_hours,
- hours_since_maintenance=hours_since,
- hours_until_due=hours_until if interval_type == "hours" else 0,
- days_since_maintenance=0 if interval_type == "days" else None,
- days_until_due=interval if interval_type == "days" else None,
- is_due=False,
- is_warning=False,
- last_performed_at=item.last_performed_at,
- )
- @router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
- async def get_maintenance_history(
- item_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
- ):
- """Get maintenance history for a specific item."""
- result = await db.execute(
- select(MaintenanceHistory)
- .where(MaintenanceHistory.printer_maintenance_id == item_id)
- .order_by(MaintenanceHistory.performed_at.desc())
- )
- return result.scalars().all()
- @router.get("/summary")
- async def get_maintenance_summary(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
- ):
- """Get a summary of maintenance status across all printers."""
- await ensure_default_types(db)
- result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
- printers = result.scalars().all()
- total_due = 0
- total_warning = 0
- printers_with_issues = []
- for printer in printers:
- overview = await get_printer_maintenance(printer.id, db)
- total_due += overview.due_count
- total_warning += overview.warning_count
- if overview.due_count > 0 or overview.warning_count > 0:
- printers_with_issues.append(
- {
- "printer_id": printer.id,
- "printer_name": printer.name,
- "due_count": overview.due_count,
- "warning_count": overview.warning_count,
- }
- )
- return {
- "total_due": total_due,
- "total_warning": total_warning,
- "printers_with_issues": printers_with_issues,
- }
- @router.patch("/printers/{printer_id}/hours")
- async def set_printer_hours(
- printer_id: int,
- total_hours: float,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
- ):
- """Set the total print hours for a printer (adjusts offset to match).
- The offset is calculated as: offset = total_hours - runtime_hours
- Where runtime_hours comes from the runtime_seconds counter that tracks
- actual machine active time (RUNNING state only — paused time excluded, #1521).
- """
- # Get printer
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(status_code=404, detail="Printer not found")
- # Get current runtime hours
- runtime_hours = (printer.runtime_seconds or 0) / 3600.0
- # Calculate needed offset
- printer.print_hours_offset = max(0, total_hours - runtime_hours)
- await db.commit()
- # Check for maintenance items that need attention and send notification
- try:
- await ensure_default_types(db)
- overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
- items_needing_attention = [
- {
- "name": item.maintenance_type_name,
- "is_due": item.is_due,
- "is_warning": item.is_warning,
- }
- for item in overview.maintenance_items
- if item.enabled and (item.is_due or item.is_warning)
- ]
- if items_needing_attention:
- await notification_service.on_maintenance_due(printer_id, printer.name, items_needing_attention, db)
- logger.info(
- f"Sent maintenance notification for printer {printer_id}: "
- f"{len(items_needing_attention)} items need attention"
- )
- except Exception as e:
- logger.warning("Failed to send maintenance notification: %s", e)
- return {
- "printer_id": printer_id,
- "total_hours": total_hours,
- "runtime_hours": runtime_hours,
- "offset_hours": printer.print_hours_offset,
- }
|