project_stream.py 2.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. import json
  2. import secrets
  3. from uuid import UUID
  4. from fastapi import APIRouter, Depends, HTTPException, Request
  5. from fastapi.responses import StreamingResponse
  6. from starlette import status
  7. from app.api import deps
  8. from app.core.events import redis_client
  9. from app.models import OrgRole, User
  10. router = APIRouter()
  11. TICKET_TTL_SECONDS = 30
  12. @router.post("/project/{project_id}/stream-ticket")
  13. async def issue_stream_ticket(
  14. project_id: UUID,
  15. current_user: User = Depends(
  16. deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
  17. ),
  18. ):
  19. ticket = secrets.token_urlsafe(32)
  20. key = f"sse_ticket:{ticket}"
  21. await redis_client.set(key, str(project_id), ex=TICKET_TTL_SECONDS)
  22. return {"ticket": ticket}
  23. async def _consume_ticket(ticket: str) -> str:
  24. key = f"sse_ticket:{ticket}"
  25. user_id = await redis_client.getdel(key)
  26. if not user_id:
  27. raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired ticket")
  28. return user_id
  29. @router.get("/project/{project_id}/stream", summary="Real-Time SSE Stream")
  30. async def project_stream(project_id: str, client_id: str, ticket: str, request: Request):
  31. """
  32. Connect to this endpoint to receive real-time updates.
  33. **Required Query Parameters:**
  34. - `client_id`: A unique UUID generated by the frontend to prevent echoing.
  35. - `ticket`: A unique ticket generated by the issue_stream_ticket endpoint.
  36. """
  37. ticket_project_id = await _consume_ticket(ticket)
  38. if ticket_project_id != project_id:
  39. raise HTTPException(
  40. status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
  41. )
  42. async def event_generator():
  43. channel = f"project_{project_id}"
  44. pubsub = redis_client.pubsub()
  45. await pubsub.subscribe(channel)
  46. try:
  47. async for event in pubsub.listen():
  48. if event["type"] != "message":
  49. continue
  50. if await request.is_disconnected():
  51. break
  52. message = json.loads(event["data"])
  53. if message.get("client_id") == client_id:
  54. continue
  55. yield f"data: {json.dumps({'event': message['event'], 'item': message['item']})}\n\n"
  56. finally:
  57. await pubsub.unsubscribe(channel)
  58. await pubsub.aclose()
  59. return StreamingResponse(event_generator(), media_type="text/event-stream")