Major overhaul to the Notes/Issues. Minor tweaks to the UI. Added Profile photos
This commit is contained in:
@@ -162,6 +162,7 @@ class DeviceUserInfo(BaseModel):
|
||||
display_name: str = ""
|
||||
email: str = ""
|
||||
role: str = ""
|
||||
photo_url: str = ""
|
||||
|
||||
|
||||
class DeviceUsersResponse(BaseModel):
|
||||
|
||||
@@ -191,6 +191,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
||||
user_id = ""
|
||||
display_name = ""
|
||||
email = ""
|
||||
photo_url = ""
|
||||
|
||||
if isinstance(user_ref, DocumentReference):
|
||||
try:
|
||||
@@ -200,6 +201,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
||||
user_id = user_doc.id
|
||||
display_name = user_data.get("display_name", "")
|
||||
email = user_data.get("email", "")
|
||||
photo_url = user_data.get("photo_url", "")
|
||||
except Exception as e:
|
||||
print(f"[devices] Error resolving user reference: {e}")
|
||||
continue
|
||||
@@ -212,6 +214,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
||||
user_id = ref_doc.id
|
||||
display_name = user_data.get("display_name", "")
|
||||
email = user_data.get("email", "")
|
||||
photo_url = user_data.get("photo_url", "")
|
||||
except Exception as e:
|
||||
print(f"[devices] Error resolving user path: {e}")
|
||||
continue
|
||||
@@ -221,6 +224,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"photo_url": photo_url,
|
||||
})
|
||||
else:
|
||||
# Fallback to user_list field
|
||||
@@ -246,6 +250,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
||||
"display_name": user_data.get("display_name", ""),
|
||||
"email": user_data.get("email", ""),
|
||||
"role": "",
|
||||
"photo_url": user_data.get("photo_url", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[devices] Error resolving user_list entry: {e}")
|
||||
|
||||
@@ -8,9 +8,10 @@ class NoteCreate(BaseModel):
|
||||
"""Create a new equipment note/log entry."""
|
||||
title: str
|
||||
content: str
|
||||
category: str = "general" # general, maintenance, installation, issue, other
|
||||
category: str = "general" # general, maintenance, installation, issue, action_item, other
|
||||
device_id: Optional[str] = None # Firestore doc ID of linked device
|
||||
user_id: Optional[str] = None # Firestore doc ID of linked user
|
||||
status: str = "" # "", "completed"
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
@@ -20,6 +21,7 @@ class NoteUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class NoteInDB(BaseModel):
|
||||
@@ -35,6 +37,7 @@ class NoteInDB(BaseModel):
|
||||
created_by: str = ""
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
class NoteListResponse(BaseModel):
|
||||
|
||||
@@ -50,6 +50,15 @@ async def update_note(
|
||||
return service.update_note(note_id, body)
|
||||
|
||||
|
||||
@router.patch("/{note_id}/status", response_model=NoteInDB)
|
||||
async def toggle_note_status(
|
||||
note_id: str,
|
||||
body: NoteUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
|
||||
):
|
||||
return service.update_note(note_id, body)
|
||||
|
||||
|
||||
@router.delete("/{note_id}", status_code=204)
|
||||
async def delete_note(
|
||||
note_id: str,
|
||||
|
||||
@@ -6,7 +6,7 @@ from equipment.models import NoteCreate, NoteUpdate, NoteInDB
|
||||
|
||||
COLLECTION = "equipment_notes"
|
||||
|
||||
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "other"}
|
||||
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"}
|
||||
|
||||
|
||||
def _convert_firestore_value(val):
|
||||
@@ -81,7 +81,10 @@ def list_notes(
|
||||
if user_id:
|
||||
query = query.where("user_id", "==", user_id)
|
||||
|
||||
query = query.order_by("created_at", direction="DESCENDING")
|
||||
# Only use order_by when no field filters are applied (avoids composite index requirement)
|
||||
has_field_filters = bool(category or device_id or user_id)
|
||||
if not has_field_filters:
|
||||
query = query.order_by("created_at", direction="DESCENDING")
|
||||
|
||||
docs = query.stream()
|
||||
results = []
|
||||
@@ -98,6 +101,10 @@ def list_notes(
|
||||
|
||||
results.append(note)
|
||||
|
||||
# Sort client-side when we couldn't use order_by
|
||||
if has_field_filters:
|
||||
results.sort(key=lambda n: n.created_at or "", reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
|
||||
0
backend/helpdesk/__init__.py
Normal file
0
backend/helpdesk/__init__.py
Normal file
24
backend/helpdesk/models.py
Normal file
24
backend/helpdesk/models.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class HelpdeskMessage(BaseModel):
|
||||
"""Helpdesk message as stored in Firestore."""
|
||||
id: str
|
||||
sender_id: str = ""
|
||||
sender_name: str = ""
|
||||
type: str = "" # Problem, Suggestion, Question, Other
|
||||
date_sent: str = ""
|
||||
subject: str = ""
|
||||
message: str = ""
|
||||
phone: str = ""
|
||||
device_id: str = ""
|
||||
device_name: str = ""
|
||||
acknowledged: bool = False
|
||||
acknowledged_by: str = ""
|
||||
acknowledged_at: str = ""
|
||||
|
||||
|
||||
class HelpdeskListResponse(BaseModel):
|
||||
messages: List[HelpdeskMessage]
|
||||
total: int
|
||||
39
backend/helpdesk/router.py
Normal file
39
backend/helpdesk/router.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from helpdesk.models import HelpdeskMessage, HelpdeskListResponse
|
||||
from helpdesk import service
|
||||
|
||||
router = APIRouter(prefix="/api/helpdesk", tags=["helpdesk"])
|
||||
|
||||
|
||||
@router.get("", response_model=HelpdeskListResponse)
|
||||
async def list_messages(
|
||||
user_id: Optional[str] = Query(None),
|
||||
device_id: Optional[str] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||
):
|
||||
messages = service.list_messages(
|
||||
user_id=user_id, device_id=device_id, msg_type=type,
|
||||
)
|
||||
return HelpdeskListResponse(messages=messages, total=len(messages))
|
||||
|
||||
|
||||
@router.get("/{message_id}", response_model=HelpdeskMessage)
|
||||
async def get_message(
|
||||
message_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||
):
|
||||
return service.get_message(message_id)
|
||||
|
||||
|
||||
@router.patch("/{message_id}/acknowledge", response_model=HelpdeskMessage)
|
||||
async def toggle_acknowledged(
|
||||
message_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
|
||||
):
|
||||
return service.toggle_acknowledged(
|
||||
message_id, acknowledged_by=_user.name or _user.email,
|
||||
)
|
||||
169
backend/helpdesk/service.py
Normal file
169
backend/helpdesk/service.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from google.cloud.firestore_v1 import DocumentReference
|
||||
|
||||
from shared.firebase import get_db
|
||||
from shared.exceptions import NotFoundError
|
||||
from helpdesk.models import HelpdeskMessage
|
||||
|
||||
COLLECTION = "helpdesk"
|
||||
|
||||
|
||||
def _convert_firestore_value(val):
|
||||
"""Convert Firestore-specific types to strings."""
|
||||
if isinstance(val, datetime):
|
||||
return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||
if isinstance(val, DocumentReference):
|
||||
return val.path
|
||||
return val
|
||||
|
||||
|
||||
def _resolve_sender(db, sender_ref) -> tuple[str, str]:
|
||||
"""Resolve a sender DocumentReference to (sender_id, sender_name)."""
|
||||
sender_id = ""
|
||||
sender_name = ""
|
||||
|
||||
try:
|
||||
if isinstance(sender_ref, DocumentReference):
|
||||
doc = sender_ref.get()
|
||||
if doc.exists:
|
||||
data = doc.to_dict()
|
||||
sender_id = doc.id
|
||||
sender_name = data.get("display_name", "") or data.get("email", "")
|
||||
elif isinstance(sender_ref, str) and sender_ref.strip():
|
||||
# String path like "users/abc123"
|
||||
doc = db.document(sender_ref).get()
|
||||
if doc.exists:
|
||||
data = doc.to_dict()
|
||||
sender_id = doc.id
|
||||
sender_name = data.get("display_name", "") or data.get("email", "")
|
||||
except Exception as e:
|
||||
print(f"[helpdesk] Error resolving sender: {e}")
|
||||
|
||||
return sender_id, sender_name
|
||||
|
||||
|
||||
def _resolve_device_name(db, device_id: str) -> str:
|
||||
"""Look up device_name from device_id."""
|
||||
if not device_id:
|
||||
return ""
|
||||
try:
|
||||
doc = db.collection("devices").document(device_id.strip()).get()
|
||||
if doc.exists:
|
||||
return doc.to_dict().get("device_name", "")
|
||||
except Exception as e:
|
||||
print(f"[helpdesk] Error resolving device name: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _doc_to_message(db, doc) -> HelpdeskMessage:
|
||||
"""Convert a Firestore document snapshot to a HelpdeskMessage."""
|
||||
data = doc.to_dict()
|
||||
|
||||
# Resolve sender reference
|
||||
sender_ref = data.get("sender")
|
||||
sender_id, sender_name = _resolve_sender(db, sender_ref)
|
||||
|
||||
# Handle date_sent (could be Firestore Timestamp)
|
||||
date_sent = data.get("date_sent", "")
|
||||
if isinstance(date_sent, datetime):
|
||||
date_sent = date_sent.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||
|
||||
# Resolve device name if device_id present
|
||||
device_id = data.get("device_id", "")
|
||||
device_name = ""
|
||||
if device_id:
|
||||
device_name = _resolve_device_name(db, device_id)
|
||||
|
||||
# Handle acknowledged_at
|
||||
acknowledged_at = data.get("acknowledged_at", "")
|
||||
if isinstance(acknowledged_at, datetime):
|
||||
acknowledged_at = acknowledged_at.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||
|
||||
return HelpdeskMessage(
|
||||
id=doc.id,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
type=data.get("type", ""),
|
||||
date_sent=date_sent,
|
||||
subject=data.get("subject", ""),
|
||||
message=data.get("message", ""),
|
||||
phone=data.get("phone", ""),
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
acknowledged=data.get("acknowledged", False),
|
||||
acknowledged_by=data.get("acknowledged_by", ""),
|
||||
acknowledged_at=acknowledged_at,
|
||||
)
|
||||
|
||||
|
||||
def list_messages(
|
||||
user_id: str | None = None,
|
||||
device_id: str | None = None,
|
||||
msg_type: str | None = None,
|
||||
) -> list[HelpdeskMessage]:
|
||||
"""List helpdesk messages with optional filters."""
|
||||
db = get_db()
|
||||
ref = db.collection(COLLECTION)
|
||||
query = ref
|
||||
|
||||
if msg_type:
|
||||
query = query.where("type", "==", msg_type)
|
||||
|
||||
if device_id:
|
||||
query = query.where("device_id", "==", device_id)
|
||||
|
||||
docs = list(query.stream())
|
||||
results = []
|
||||
|
||||
for doc in docs:
|
||||
msg = _doc_to_message(db, doc)
|
||||
|
||||
# Filter by sender user_id client-side (sender is a DocumentReference)
|
||||
if user_id and msg.sender_id != user_id:
|
||||
continue
|
||||
|
||||
results.append(msg)
|
||||
|
||||
# Sort by date_sent descending
|
||||
results.sort(key=lambda m: m.date_sent or "", reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_message(message_id: str) -> HelpdeskMessage:
|
||||
"""Get a single helpdesk message by ID."""
|
||||
db = get_db()
|
||||
doc = db.collection(COLLECTION).document(message_id).get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Helpdesk message")
|
||||
return _doc_to_message(db, doc)
|
||||
|
||||
|
||||
def toggle_acknowledged(message_id: str, acknowledged_by: str = "") -> HelpdeskMessage:
|
||||
"""Toggle the acknowledged status of a helpdesk message."""
|
||||
db = get_db()
|
||||
doc_ref = db.collection(COLLECTION).document(message_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Helpdesk message")
|
||||
|
||||
data = doc.to_dict()
|
||||
currently_acknowledged = data.get("acknowledged", False)
|
||||
now = datetime.now(timezone.utc).strftime("%d %B %Y at %H:%M:%S UTC")
|
||||
|
||||
if currently_acknowledged:
|
||||
doc_ref.update({
|
||||
"acknowledged": False,
|
||||
"acknowledged_by": "",
|
||||
"acknowledged_at": "",
|
||||
})
|
||||
else:
|
||||
doc_ref.update({
|
||||
"acknowledged": True,
|
||||
"acknowledged_by": acknowledged_by,
|
||||
"acknowledged_at": now,
|
||||
})
|
||||
|
||||
updated_doc = doc_ref.get()
|
||||
return _doc_to_message(db, updated_doc)
|
||||
@@ -11,6 +11,7 @@ from users.router import router as users_router
|
||||
from mqtt.router import router as mqtt_router
|
||||
from equipment.router import router as equipment_router
|
||||
from staff.router import router as staff_router
|
||||
from helpdesk.router import router as helpdesk_router
|
||||
from mqtt.client import mqtt_manager
|
||||
from mqtt import database as mqtt_db
|
||||
from melodies import service as melody_service
|
||||
@@ -37,6 +38,7 @@ app.include_router(settings_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(mqtt_router)
|
||||
app.include_router(equipment_router)
|
||||
app.include_router(helpdesk_router)
|
||||
app.include_router(staff_router)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||
from typing import Optional, List
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
@@ -93,3 +93,15 @@ async def unassign_device(
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
return service.unassign_device(user_id, device_id)
|
||||
|
||||
|
||||
@router.post("/{user_id}/photo")
|
||||
async def upload_photo(
|
||||
user_id: str,
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
contents = await file.read()
|
||||
content_type = file.content_type or "image/jpeg"
|
||||
url = service.upload_photo(user_id, contents, file.filename, content_type)
|
||||
return {"photo_url": url}
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from google.cloud.firestore_v1 import DocumentReference
|
||||
|
||||
from shared.firebase import get_db
|
||||
from shared.firebase import get_db, get_bucket
|
||||
from shared.exceptions import NotFoundError
|
||||
from users.models import UserCreate, UserUpdate, UserInDB
|
||||
|
||||
@@ -250,3 +250,29 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
|
||||
break
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
|
||||
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
|
||||
db = get_db()
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
raise RuntimeError("Firebase Storage not initialized")
|
||||
|
||||
# Verify user exists
|
||||
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("User")
|
||||
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else "jpg"
|
||||
storage_path = f"users/{user_doc_id}/uploads/profile.{ext}"
|
||||
|
||||
blob = bucket.blob(storage_path)
|
||||
blob.upload_from_string(file_bytes, content_type=content_type)
|
||||
blob.make_public()
|
||||
|
||||
photo_url = blob.public_url
|
||||
doc_ref.update({"photo_url": photo_url})
|
||||
|
||||
return photo_url
|
||||
|
||||
Reference in New Issue
Block a user