device_service.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. """
  2. Device management service.
  3. """
  4. from datetime import datetime, timezone
  5. from sqlalchemy import String, func, or_, select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy.orm import joinedload
  8. from app.models.device import Device
  9. from app.models.organization import Organization
  10. from app.schemas.device import DeviceCreate, DeviceUpdate
  11. async def create_device(
  12. db: AsyncSession,
  13. data: DeviceCreate,
  14. ) -> Device:
  15. """
  16. Create a new device.
  17. Args:
  18. db: Database session
  19. data: Device creation data
  20. Returns:
  21. Created device
  22. """
  23. # Check if MAC address already exists
  24. result = await db.execute(
  25. select(Device).where(Device.mac_address == data.mac_address)
  26. )
  27. existing_device = result.scalar_one_or_none()
  28. if existing_device:
  29. raise ValueError(f"Device with MAC {data.mac_address} already exists")
  30. device = Device(
  31. mac_address=data.mac_address,
  32. organization_id=data.organization_id,
  33. status="offline",
  34. config=data.config or {},
  35. # simple_id will be auto-generated by PostgreSQL sequence
  36. )
  37. db.add(device)
  38. await db.commit()
  39. await db.refresh(device)
  40. return device
  41. async def get_device(db: AsyncSession, device_id: int) -> Device | None:
  42. """
  43. Get device by ID.
  44. Args:
  45. db: Database session
  46. device_id: Device ID
  47. Returns:
  48. Device or None
  49. """
  50. result = await db.execute(select(Device).where(Device.id == device_id))
  51. return result.scalar_one_or_none()
  52. async def get_device_by_mac(db: AsyncSession, mac_address: str) -> Device | None:
  53. """
  54. Get device by MAC address.
  55. Args:
  56. db: Database session
  57. mac_address: Device MAC address
  58. Returns:
  59. Device or None
  60. """
  61. result = await db.execute(
  62. select(Device).where(Device.mac_address == mac_address)
  63. )
  64. return result.scalar_one_or_none()
  65. async def list_devices(
  66. db: AsyncSession,
  67. skip: int = 0,
  68. limit: int = 100,
  69. organization_id: int | None = None,
  70. status: str | None = None,
  71. search: str | None = None,
  72. ) -> tuple[list[Device], int]:
  73. """
  74. List devices with pagination and filters.
  75. Args:
  76. db: Database session
  77. skip: Number of records to skip
  78. limit: Maximum number of records to return
  79. organization_id: Filter by organization (optional)
  80. status: Filter by status (optional)
  81. search: Universal search across all fields (optional)
  82. Returns:
  83. Tuple of (devices list, total count)
  84. """
  85. # Build query
  86. query = select(Device)
  87. # Base filters
  88. filters = []
  89. if organization_id is not None:
  90. filters.append(Device.organization_id == organization_id)
  91. if status:
  92. filters.append(Device.status == status)
  93. # Universal search filter - requires join with Organization
  94. if search and len(search) >= 2:
  95. # Join with Organization for searching by org name/email
  96. query = query.outerjoin(Organization, Device.organization_id == Organization.id)
  97. # Search across multiple fields
  98. search_pattern = f"%{search}%"
  99. search_filters = [
  100. Device.mac_address.ilike(search_pattern),
  101. func.cast(Device.simple_id, String).ilike(search_pattern),
  102. Organization.name.ilike(search_pattern),
  103. Organization.contact_email.ilike(search_pattern),
  104. ]
  105. filters.append(or_(*search_filters))
  106. # Always load organization relationship
  107. query = query.options(joinedload(Device.organization))
  108. # Apply all filters
  109. if filters:
  110. query = query.where(*filters)
  111. # Get total count
  112. count_query = select(func.count()).select_from(Device)
  113. if search and len(search) >= 2:
  114. count_query = count_query.join(
  115. Organization, Device.organization_id == Organization.id, isouter=True
  116. )
  117. if filters:
  118. count_query = count_query.where(*filters)
  119. total_result = await db.execute(count_query)
  120. total = total_result.scalar_one()
  121. # Get paginated results
  122. query = query.offset(skip).limit(limit).order_by(Device.simple_id.desc())
  123. result = await db.execute(query)
  124. devices = list(result.scalars().all())
  125. return devices, total
  126. async def update_device(
  127. db: AsyncSession,
  128. device_id: int,
  129. data: DeviceUpdate,
  130. ) -> Device | None:
  131. """
  132. Update device.
  133. Args:
  134. db: Database session
  135. device_id: Device ID
  136. data: Update data
  137. Returns:
  138. Updated device or None if not found
  139. """
  140. result = await db.execute(select(Device).where(Device.id == device_id))
  141. device = result.scalar_one_or_none()
  142. if not device:
  143. return None
  144. # Update fields
  145. update_data = data.model_dump(exclude_unset=True)
  146. for field, value in update_data.items():
  147. setattr(device, field, value)
  148. await db.commit()
  149. await db.refresh(device)
  150. return device
  151. async def delete_device(
  152. db: AsyncSession,
  153. device_id: int,
  154. ) -> bool:
  155. """
  156. Delete device.
  157. Args:
  158. db: Database session
  159. device_id: Device ID
  160. Returns:
  161. True if deleted, False if not found
  162. """
  163. result = await db.execute(select(Device).where(Device.id == device_id))
  164. device = result.scalar_one_or_none()
  165. if not device:
  166. return False
  167. await db.delete(device)
  168. await db.commit()
  169. return True
  170. async def update_device_heartbeat(
  171. db: AsyncSession,
  172. mac_address: str,
  173. ) -> Device | None:
  174. """
  175. Update device last_seen_at timestamp (heartbeat).
  176. Args:
  177. db: Database session
  178. mac_address: Device MAC address
  179. Returns:
  180. Updated device or None if not found
  181. """
  182. result = await db.execute(
  183. select(Device).where(Device.mac_address == mac_address)
  184. )
  185. device = result.scalar_one_or_none()
  186. if not device:
  187. return None
  188. device.last_seen_at = datetime.now(timezone.utc)
  189. device.status = "online"
  190. await db.commit()
  191. await db.refresh(device)
  192. return device