diff --git a/backend/devices/models.py b/backend/devices/models.py index 4e5634f..32f2a39 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -75,6 +75,7 @@ class DeviceAttributes(BaseModel): networkSettings: DeviceNetworkSettings = DeviceNetworkSettings() serialLogLevel: int = 0 sdLogLevel: int = 0 + mqttLogLevel: int = 0 class DeviceSubInformation(BaseModel): @@ -151,3 +152,16 @@ class DeviceInDB(DeviceCreate): class DeviceListResponse(BaseModel): devices: List[DeviceInDB] total: int + + +class DeviceUserInfo(BaseModel): + """User info resolved from device_users sub-collection or user_list.""" + user_id: str = "" + display_name: str = "" + email: str = "" + role: str = "" + + +class DeviceUsersResponse(BaseModel): + users: List[DeviceUserInfo] + total: int diff --git a/backend/devices/router.py b/backend/devices/router.py index 2886fbb..39a00ee 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -4,6 +4,7 @@ from auth.models import TokenPayload from auth.dependencies import require_device_access, require_viewer from devices.models import ( DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse, + DeviceUsersResponse, DeviceUserInfo, ) from devices import service @@ -33,6 +34,16 @@ async def get_device( return service.get_device(device_id) +@router.get("/{device_id}/users", response_model=DeviceUsersResponse) +async def get_device_users( + device_id: str, + _user: TokenPayload = Depends(require_viewer), +): + users_data = service.get_device_users(device_id) + users = [DeviceUserInfo(**u) for u in users_data] + return DeviceUsersResponse(users=users, total=len(users)) + + @router.post("", response_model=DeviceInDB, status_code=201) async def create_device( body: DeviceCreate, diff --git a/backend/devices/service.py b/backend/devices/service.py index 8289d56..f49acdb 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -165,6 +165,94 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB: return _doc_to_device(updated_doc) +def get_device_users(device_doc_id: str) -> list[dict]: + """Get users assigned to a device from the device_users sub-collection. + + Falls back to the user_list field on the device document if the + sub-collection is empty. + """ + db = get_db() + doc_ref = db.collection(COLLECTION).document(device_doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Device") + + # Try sub-collection first + sub_docs = list(doc_ref.collection("device_users").stream()) + users = [] + + if sub_docs: + for sub_doc in sub_docs: + sub_data = sub_doc.to_dict() + role = sub_data.get("role", "") + user_ref = sub_data.get("user_reference") + + # Resolve user reference + user_id = "" + display_name = "" + email = "" + + if isinstance(user_ref, DocumentReference): + try: + user_doc = user_ref.get() + if user_doc.exists: + user_data = user_doc.to_dict() + user_id = user_doc.id + display_name = user_data.get("display_name", "") + email = user_data.get("email", "") + except Exception as e: + print(f"[devices] Error resolving user reference: {e}") + continue + elif isinstance(user_ref, str): + # String path like "users/abc123" + try: + ref_doc = db.document(user_ref).get() + if ref_doc.exists: + user_data = ref_doc.to_dict() + user_id = ref_doc.id + display_name = user_data.get("display_name", "") + email = user_data.get("email", "") + except Exception as e: + print(f"[devices] Error resolving user path: {e}") + continue + + users.append({ + "user_id": user_id, + "display_name": display_name, + "email": email, + "role": role, + }) + else: + # Fallback to user_list field + device_data = doc.to_dict() + user_list = device_data.get("user_list", []) + for entry in user_list: + try: + if isinstance(entry, DocumentReference): + user_doc = entry.get() + elif isinstance(entry, str) and entry.strip(): + # Could be a path like "users/abc" or a raw doc ID + if "/" in entry: + user_doc = db.document(entry).get() + else: + user_doc = db.collection("users").document(entry).get() + else: + continue + + if user_doc.exists: + user_data = user_doc.to_dict() + users.append({ + "user_id": user_doc.id, + "display_name": user_data.get("display_name", ""), + "email": user_data.get("email", ""), + "role": "", + }) + except Exception as e: + print(f"[devices] Error resolving user_list entry: {e}") + + return users + + def delete_device(device_doc_id: str) -> None: """Delete a device document from Firestore.""" db = get_db() diff --git a/backend/equipment/service.py b/backend/equipment/service.py index 3086ad5..e13edb4 100644 --- a/backend/equipment/service.py +++ b/backend/equipment/service.py @@ -44,16 +44,19 @@ def _resolve_names(db, device_id: str | None, user_id: str | None) -> tuple[str, device_name = "" user_name = "" - if device_id: - device_doc = db.collection("devices").document(device_id).get() - if device_doc.exists: - device_name = device_doc.to_dict().get("device_name", "") + try: + if device_id and isinstance(device_id, str) and device_id.strip(): + device_doc = db.collection("devices").document(device_id.strip()).get() + if device_doc.exists: + device_name = device_doc.to_dict().get("device_name", "") - if user_id: - user_doc = db.collection("users").document(user_id).get() - if user_doc.exists: - user_doc_data = user_doc.to_dict() - user_name = user_doc_data.get("display_name", "") or user_doc_data.get("email", "") + if user_id and isinstance(user_id, str) and user_id.strip(): + user_doc = db.collection("users").document(user_id.strip()).get() + if user_doc.exists: + user_doc_data = user_doc.to_dict() + user_name = user_doc_data.get("display_name", "") or user_doc_data.get("email", "") + except Exception as e: + print(f"[equipment] Error resolving names (device_id={device_id}, user_id={user_id}): {e}") return device_name, user_name diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a3c03ab..ab26280 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.13.0" }, "devDependencies": { @@ -1009,6 +1011,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2614,6 +2627,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3158,6 +3177,20 @@ "react": "^19.2.4" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5708fa0..8b6d23b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.13.0" }, "devDependencies": { diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index c51d1ab..4691b3b 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -1,17 +1,27 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; +import { MapContainer, TileLayer, Marker } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; import NotesPanel from "../equipment/NotesPanel"; +// Fix default Leaflet marker icon +delete L.Icon.Default.prototype._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", +}); + +// --- Helper components --- + function Field({ label, children }) { return (
Loading users...
+ ) : deviceUsers.length === 0 ? ( +No users assigned to this device.
+ ) : ( ++ {user.display_name || user.email || "Unknown User"} +
+ {user.email && user.display_name && ( +{user.email}
+ )} + {user.user_id && ( +{user.user_id}
+ )} +