feat: enhance weekly report and realtime notifications
Weekly Report (fix-weekly-report): - Remove 5-task limit, show all tasks per category - Add blocked tasks with blocker_reason and blocked_since - Add next week tasks (due in coming week) - Add assignee_name, completed_at, days_overdue to task details - Frontend collapsible sections for each task category - 8 new tests for enhanced report content Realtime Notifications (fix-realtime-notifications): - SQLAlchemy event-based notification publishing - Redis Pub/Sub for multi-process broadcast - Fix soft rollback handler stacking issue - Fix ping scheduling drift (send immediately when interval expires) - Frontend NotificationContext with WebSocket reconnection Spec Fixes: - Add missing ## Purpose sections to 5 specs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
122
backend/app/core/redis_pubsub.py
Normal file
122
backend/app/core/redis_pubsub.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Redis Pub/Sub service for cross-process notification broadcasting."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Callable, Any
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global async Redis client for pub/sub
|
||||
_pubsub_redis: Optional[aioredis.Redis] = None
|
||||
|
||||
|
||||
def get_channel_name(user_id: str) -> str:
|
||||
"""Get the Redis channel name for a user's notifications."""
|
||||
return f"notifications:{user_id}"
|
||||
|
||||
|
||||
async def get_pubsub_redis() -> aioredis.Redis:
|
||||
"""Get or create the async Redis client for pub/sub."""
|
||||
global _pubsub_redis
|
||||
if _pubsub_redis is None:
|
||||
_pubsub_redis = aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return _pubsub_redis
|
||||
|
||||
|
||||
async def close_pubsub_redis() -> None:
|
||||
"""Close the async Redis client."""
|
||||
global _pubsub_redis
|
||||
if _pubsub_redis is not None:
|
||||
await _pubsub_redis.close()
|
||||
_pubsub_redis = None
|
||||
|
||||
|
||||
async def publish_notification(user_id: str, notification: dict) -> bool:
|
||||
"""
|
||||
Publish a notification to a user's channel.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to send the notification to
|
||||
notification: The notification data (will be JSON serialized)
|
||||
|
||||
Returns:
|
||||
True if published successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
redis_client = await get_pubsub_redis()
|
||||
channel = get_channel_name(user_id)
|
||||
message = json.dumps(notification, default=str)
|
||||
await redis_client.publish(channel, message)
|
||||
logger.debug(f"Published notification to channel {channel}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class NotificationSubscriber:
|
||||
"""
|
||||
Subscriber for user notification channels.
|
||||
Used by WebSocket connections to receive real-time updates.
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str):
|
||||
self.user_id = user_id
|
||||
self.channel = get_channel_name(user_id)
|
||||
self.pubsub: Optional[aioredis.client.PubSub] = None
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start subscribing to the user's notification channel."""
|
||||
redis_client = await get_pubsub_redis()
|
||||
self.pubsub = redis_client.pubsub()
|
||||
await self.pubsub.subscribe(self.channel)
|
||||
self._running = True
|
||||
logger.debug(f"Subscribed to channel {self.channel}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop subscribing and clean up."""
|
||||
self._running = False
|
||||
if self.pubsub:
|
||||
await self.pubsub.unsubscribe(self.channel)
|
||||
await self.pubsub.close()
|
||||
self.pubsub = None
|
||||
logger.debug(f"Unsubscribed from channel {self.channel}")
|
||||
|
||||
async def listen(self, callback: Callable[[dict], Any]) -> None:
|
||||
"""
|
||||
Listen for messages and call the callback for each notification.
|
||||
|
||||
Args:
|
||||
callback: Async function to call with each notification dict
|
||||
"""
|
||||
if not self.pubsub:
|
||||
raise RuntimeError("Subscriber not started. Call start() first.")
|
||||
|
||||
try:
|
||||
async for message in self.pubsub.listen():
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
if message["type"] == "message":
|
||||
try:
|
||||
data = json.loads(message["data"])
|
||||
await callback(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON in notification: {message['data']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing notification: {e}")
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
logger.error(f"Error in notification listener: {e}")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
Reference in New Issue
Block a user