✨ New Features: - AI-powered habit creation with natural language processing - HuggingFace transformers integration for sentiment analysis (tracked via Git LFS) - Advanced predictive analytics and behavioral insights - Voice & image input capabilities for hands-free habit tracking - Real-time notifications and community features - Plugin system with extensible architecture 🔧 Technical Improvements: - Comprehensive FastAPI backend with 30+ endpoints - React frontend with PWA capabilities - Advanced authentication with 2FA support - RBAC authorization system - Comprehensive security features (CSRF, rate limiting, audit logging) - Database migrations and health monitoring - Docker containerization support - Git LFS configured for large AI model files (2+ GB) 📚 Documentation & DevOps: - Complete deployment guides for multiple platforms - Professional README with feature highlights - GitHub Actions CI/CD workflows - Comprehensive API documentation - Security audit roadmap and compliance framework - Setup scripts for development environment 🧪 Testing & Quality: - Comprehensive test suite with 20+ test modules - Setup verification scripts - Working development environment with both backend and frontend - Health checks and monitoring systems 🌟 Ready for: - Portfolio showcasing - Community contributions - Production deployment - Professional presentation
614 lines
22 KiB
Python
614 lines
22 KiB
Python
"""
|
|
Real-time Notifications System with WebSocket Support
|
|
Provides instant notifications for habit reminders, achievements, and social interactions
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Any, Set
|
|
from dataclasses import dataclass, asdict
|
|
from enum import Enum
|
|
import uuid
|
|
from fastapi import WebSocket, WebSocketDisconnect
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text
|
|
import logging
|
|
|
|
from .models import User, Habit, Log
|
|
from .db import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NotificationType(Enum):
|
|
HABIT_REMINDER = "habit_reminder"
|
|
ACHIEVEMENT_UNLOCKED = "achievement_unlocked"
|
|
QUEST_COMPLETED = "quest_completed"
|
|
STREAK_MILESTONE = "streak_milestone"
|
|
GUILD_INVITATION = "guild_invitation"
|
|
BUDDY_ENCOURAGEMENT = "buddy_encouragement"
|
|
CHALLENGE_UPDATE = "challenge_update"
|
|
SOCIAL_INTERACTION = "social_interaction"
|
|
SYSTEM_ANNOUNCEMENT = "system_announcement"
|
|
|
|
|
|
class NotificationPriority(Enum):
|
|
LOW = 1
|
|
MEDIUM = 2
|
|
HIGH = 3
|
|
URGENT = 4
|
|
|
|
|
|
@dataclass
|
|
class Notification:
|
|
"""Represents a notification to be sent to a user"""
|
|
id: str
|
|
user_id: int
|
|
type: NotificationType
|
|
title: str
|
|
message: str
|
|
data: Dict[str, Any]
|
|
priority: NotificationPriority
|
|
created_at: datetime
|
|
scheduled_for: Optional[datetime] = None
|
|
read_at: Optional[datetime] = None
|
|
clicked_at: Optional[datetime] = None
|
|
action_url: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class HabitReminder:
|
|
"""Habit reminder configuration"""
|
|
habit_id: int
|
|
user_id: int
|
|
reminder_time: str # HH:MM format
|
|
days_of_week: List[int] # 0=Monday, 6=Sunday
|
|
is_active: bool
|
|
message_template: str
|
|
advance_minutes: int = 0 # Remind X minutes before
|
|
|
|
|
|
class WebSocketManager:
|
|
"""Manages WebSocket connections for real-time notifications"""
|
|
|
|
def __init__(self):
|
|
self.active_connections: Dict[int, Set[WebSocket]] = {}
|
|
self.connection_metadata: Dict[WebSocket, Dict] = {}
|
|
|
|
async def connect(self, websocket: WebSocket, user_id: int,
|
|
device_info: Optional[Dict] = None):
|
|
"""Accept a new WebSocket connection"""
|
|
await websocket.accept()
|
|
|
|
if user_id not in self.active_connections:
|
|
self.active_connections[user_id] = set()
|
|
|
|
self.active_connections[user_id].add(websocket)
|
|
self.connection_metadata[websocket] = {
|
|
"user_id": user_id,
|
|
"connected_at": datetime.now(),
|
|
"device_info": device_info or {},
|
|
"last_ping": datetime.now()
|
|
}
|
|
|
|
logger.info(f"WebSocket connected for user {user_id}")
|
|
|
|
# Send connection confirmation
|
|
await self.send_to_user(user_id, {
|
|
"type": "connection_established",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"message": "Real-time notifications are now active!"
|
|
})
|
|
|
|
def disconnect(self, websocket: WebSocket):
|
|
"""Handle WebSocket disconnection"""
|
|
if websocket in self.connection_metadata:
|
|
user_id = self.connection_metadata[websocket]["user_id"]
|
|
|
|
if user_id in self.active_connections:
|
|
self.active_connections[user_id].discard(websocket)
|
|
|
|
# Remove user entry if no more connections
|
|
if not self.active_connections[user_id]:
|
|
del self.active_connections[user_id]
|
|
|
|
del self.connection_metadata[websocket]
|
|
logger.info(f"WebSocket disconnected for user {user_id}")
|
|
|
|
async def send_to_user(self, user_id: int, data: Dict):
|
|
"""Send data to all connections for a specific user"""
|
|
if user_id in self.active_connections:
|
|
disconnected_connections = set()
|
|
|
|
for websocket in self.active_connections[user_id].copy():
|
|
try:
|
|
await websocket.send_text(json.dumps(data))
|
|
except Exception as e:
|
|
logger.error(f"Error sending to websocket: {e}")
|
|
disconnected_connections.add(websocket)
|
|
|
|
# Clean up disconnected connections
|
|
for websocket in disconnected_connections:
|
|
self.disconnect(websocket)
|
|
|
|
async def send_to_all(self, data: Dict):
|
|
"""Send data to all connected users"""
|
|
for user_id in list(self.active_connections.keys()):
|
|
await self.send_to_user(user_id, data)
|
|
|
|
async def ping_connections(self):
|
|
"""Send ping to maintain connections"""
|
|
ping_data = {"type": "ping", "timestamp": datetime.now().isoformat()}
|
|
|
|
for user_id in list(self.active_connections.keys()):
|
|
await self.send_to_user(user_id, ping_data)
|
|
|
|
def get_connected_users(self) -> List[int]:
|
|
"""Get list of currently connected user IDs"""
|
|
return list(self.active_connections.keys())
|
|
|
|
def get_user_connection_count(self, user_id: int) -> int:
|
|
"""Get number of active connections for a user"""
|
|
return len(self.active_connections.get(user_id, set()))
|
|
|
|
|
|
class NotificationManager:
|
|
"""Manages notification creation, scheduling, and delivery"""
|
|
|
|
def __init__(self, db_session: Session, websocket_manager: WebSocketManager):
|
|
self.db = db_session
|
|
self.ws_manager = websocket_manager
|
|
self.scheduled_notifications: List[Notification] = []
|
|
|
|
async def create_notification(self, notification: Notification) -> str:
|
|
"""Create and optionally schedule a notification"""
|
|
|
|
# Save to database
|
|
query = """
|
|
INSERT INTO notifications (id, user_id, type, title, message, data,
|
|
priority, created_at, scheduled_for, action_url, image_url)
|
|
VALUES (:id, :user_id, :type, :title, :message, :data,
|
|
:priority, :created_at, :scheduled_for, :action_url, :image_url)
|
|
"""
|
|
|
|
await self.db.execute(text(query), {
|
|
"id": notification.id,
|
|
"user_id": notification.user_id,
|
|
"type": notification.type.value,
|
|
"title": notification.title,
|
|
"message": notification.message,
|
|
"data": json.dumps(notification.data),
|
|
"priority": notification.priority.value,
|
|
"created_at": notification.created_at,
|
|
"scheduled_for": notification.scheduled_for,
|
|
"action_url": notification.action_url,
|
|
"image_url": notification.image_url
|
|
})
|
|
|
|
# Send immediately if not scheduled
|
|
if notification.scheduled_for is None:
|
|
await self._send_notification(notification)
|
|
else:
|
|
self.scheduled_notifications.append(notification)
|
|
|
|
return notification.id
|
|
|
|
async def _send_notification(self, notification: Notification):
|
|
"""Send notification via WebSocket and mark as sent"""
|
|
|
|
notification_data = {
|
|
"type": "notification",
|
|
"notification": {
|
|
"id": notification.id,
|
|
"type": notification.type.value,
|
|
"title": notification.title,
|
|
"message": notification.message,
|
|
"priority": notification.priority.value,
|
|
"created_at": notification.created_at.isoformat(),
|
|
"action_url": notification.action_url,
|
|
"image_url": notification.image_url,
|
|
"data": notification.data
|
|
}
|
|
}
|
|
|
|
await self.ws_manager.send_to_user(notification.user_id, notification_data)
|
|
|
|
# Log notification sent
|
|
logger.info(f"Notification sent: {notification.id} to user {notification.user_id}")
|
|
|
|
async def send_habit_reminder(self, habit_id: int, user_id: int, custom_message: Optional[str] = None):
|
|
"""Send a habit reminder notification"""
|
|
|
|
# Get habit details
|
|
query = """
|
|
SELECT title, description FROM habits WHERE id = :habit_id AND user_id = :user_id
|
|
"""
|
|
result = await self.db.execute(text(query), {"habit_id": habit_id, "user_id": user_id})
|
|
habit = result.first()
|
|
|
|
if not habit:
|
|
return
|
|
|
|
message = custom_message or f"Time to work on your {habit.title} habit!"
|
|
|
|
notification = Notification(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
type=NotificationType.HABIT_REMINDER,
|
|
title="🔔 Habit Reminder",
|
|
message=message,
|
|
data={
|
|
"habit_id": habit_id,
|
|
"habit_title": habit.title,
|
|
"habit_description": habit.description
|
|
},
|
|
priority=NotificationPriority.MEDIUM,
|
|
created_at=datetime.now(),
|
|
action_url=f"/habits/{habit_id}"
|
|
)
|
|
|
|
await self.create_notification(notification)
|
|
|
|
async def send_achievement_notification(self, user_id: int, achievement_data: Dict):
|
|
"""Send achievement unlocked notification"""
|
|
|
|
notification = Notification(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
type=NotificationType.ACHIEVEMENT_UNLOCKED,
|
|
title="🏆 Achievement Unlocked!",
|
|
message=f"Congratulations! You've earned: {achievement_data['title']}",
|
|
data=achievement_data,
|
|
priority=NotificationPriority.HIGH,
|
|
created_at=datetime.now(),
|
|
action_url="/achievements",
|
|
image_url=achievement_data.get("icon_url")
|
|
)
|
|
|
|
await self.create_notification(notification)
|
|
|
|
async def send_streak_milestone(self, user_id: int, habit_id: int, streak_count: int):
|
|
"""Send streak milestone notification"""
|
|
|
|
# Get habit title
|
|
query = "SELECT title FROM habits WHERE id = :habit_id"
|
|
result = await self.db.execute(text(query), {"habit_id": habit_id})
|
|
habit = result.first()
|
|
|
|
if not habit:
|
|
return
|
|
|
|
milestone_messages = {
|
|
7: "Amazing! One week strong! 🔥",
|
|
14: "Two weeks of consistency! You're on fire! 🌟",
|
|
30: "30-day streak! You're a habit hero! 🦸♀️",
|
|
50: "50 days! Nothing can stop you now! 💪",
|
|
100: "100-day milestone! You're absolutely legendary! 👑"
|
|
}
|
|
|
|
message = milestone_messages.get(streak_count, f"{streak_count}-day streak! Keep it up!")
|
|
|
|
notification = Notification(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
type=NotificationType.STREAK_MILESTONE,
|
|
title="🔥 Streak Milestone!",
|
|
message=f"{habit.title}: {message}",
|
|
data={
|
|
"habit_id": habit_id,
|
|
"habit_title": habit.title,
|
|
"streak_count": streak_count
|
|
},
|
|
priority=NotificationPriority.HIGH,
|
|
created_at=datetime.now(),
|
|
action_url=f"/habits/{habit_id}"
|
|
)
|
|
|
|
await self.create_notification(notification)
|
|
|
|
async def send_social_notification(self, user_id: int, notification_data: Dict):
|
|
"""Send social interaction notification"""
|
|
|
|
notification_types = {
|
|
"like": "liked your post",
|
|
"comment": "commented on your post",
|
|
"follow": "started following you",
|
|
"buddy_request": "sent you a habit buddy request",
|
|
"guild_invite": "invited you to join their guild"
|
|
}
|
|
|
|
action_type = notification_data["action_type"]
|
|
from_user = notification_data["from_user"]
|
|
|
|
message = f"{from_user} {notification_types.get(action_type, 'interacted with you')}"
|
|
|
|
notification = Notification(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
type=NotificationType.SOCIAL_INTERACTION,
|
|
title="👥 Social Update",
|
|
message=message,
|
|
data=notification_data,
|
|
priority=NotificationPriority.MEDIUM,
|
|
created_at=datetime.now(),
|
|
action_url=notification_data.get("action_url", "/social")
|
|
)
|
|
|
|
await self.create_notification(notification)
|
|
|
|
async def process_scheduled_notifications(self):
|
|
"""Process and send scheduled notifications"""
|
|
|
|
now = datetime.now()
|
|
notifications_to_send = []
|
|
|
|
for notification in self.scheduled_notifications[:]:
|
|
if notification.scheduled_for and notification.scheduled_for <= now:
|
|
notifications_to_send.append(notification)
|
|
self.scheduled_notifications.remove(notification)
|
|
|
|
for notification in notifications_to_send:
|
|
await self._send_notification(notification)
|
|
|
|
async def get_user_notifications(self, user_id: int, limit: int = 50,
|
|
unread_only: bool = False) -> List[Dict]:
|
|
"""Get notifications for a user"""
|
|
|
|
query = """
|
|
SELECT * FROM notifications
|
|
WHERE user_id = :user_id
|
|
"""
|
|
|
|
if unread_only:
|
|
query += " AND read_at IS NULL"
|
|
|
|
query += " ORDER BY created_at DESC LIMIT :limit"
|
|
|
|
result = await self.db.execute(text(query), {
|
|
"user_id": user_id,
|
|
"limit": limit
|
|
})
|
|
|
|
notifications = []
|
|
for row in result:
|
|
notifications.append({
|
|
"id": row.id,
|
|
"type": row.type,
|
|
"title": row.title,
|
|
"message": row.message,
|
|
"data": json.loads(row.data or '{}'),
|
|
"priority": row.priority,
|
|
"created_at": row.created_at,
|
|
"read_at": row.read_at,
|
|
"action_url": row.action_url,
|
|
"image_url": row.image_url
|
|
})
|
|
|
|
return notifications
|
|
|
|
async def mark_notification_read(self, notification_id: str, user_id: int):
|
|
"""Mark a notification as read"""
|
|
|
|
query = """
|
|
UPDATE notifications
|
|
SET read_at = :read_at
|
|
WHERE id = :notification_id AND user_id = :user_id
|
|
"""
|
|
|
|
await self.db.execute(text(query), {
|
|
"notification_id": notification_id,
|
|
"user_id": user_id,
|
|
"read_at": datetime.now()
|
|
})
|
|
|
|
async def get_unread_count(self, user_id: int) -> int:
|
|
"""Get count of unread notifications for a user"""
|
|
|
|
query = """
|
|
SELECT COUNT(*) as unread_count
|
|
FROM notifications
|
|
WHERE user_id = :user_id AND read_at IS NULL
|
|
"""
|
|
|
|
result = await self.db.execute(text(query), {"user_id": user_id})
|
|
row = result.first()
|
|
|
|
return row.unread_count if row else 0
|
|
|
|
|
|
class HabitReminderService:
|
|
"""Manages habit reminders and scheduling"""
|
|
|
|
def __init__(self, db_session: Session, notification_manager: NotificationManager):
|
|
self.db = db_session
|
|
self.notification_manager = notification_manager
|
|
|
|
async def create_habit_reminder(self, reminder_data: Dict) -> str:
|
|
"""Create a new habit reminder"""
|
|
|
|
reminder_id = str(uuid.uuid4())
|
|
|
|
query = """
|
|
INSERT INTO habit_reminders (id, habit_id, user_id, reminder_time,
|
|
days_of_week, is_active, message_template, advance_minutes)
|
|
VALUES (:id, :habit_id, :user_id, :reminder_time,
|
|
:days_of_week, :is_active, :message_template, :advance_minutes)
|
|
"""
|
|
|
|
await self.db.execute(text(query), {
|
|
"id": reminder_id,
|
|
"habit_id": reminder_data["habit_id"],
|
|
"user_id": reminder_data["user_id"],
|
|
"reminder_time": reminder_data["reminder_time"],
|
|
"days_of_week": json.dumps(reminder_data.get("days_of_week", [0, 1, 2, 3, 4, 5, 6])),
|
|
"is_active": reminder_data.get("is_active", True),
|
|
"message_template": reminder_data.get("message_template", ""),
|
|
"advance_minutes": reminder_data.get("advance_minutes", 0)
|
|
})
|
|
|
|
return reminder_id
|
|
|
|
async def get_due_reminders(self) -> List[HabitReminder]:
|
|
"""Get habit reminders that are due to be sent"""
|
|
|
|
now = datetime.now()
|
|
current_time = now.strftime("%H:%M")
|
|
current_weekday = now.weekday()
|
|
|
|
query = """
|
|
SELECT hr.*, h.title as habit_title
|
|
FROM habit_reminders hr
|
|
JOIN habits h ON hr.habit_id = h.id
|
|
WHERE hr.is_active = true
|
|
AND hr.reminder_time = :current_time
|
|
AND JSON_EXTRACT(hr.days_of_week, '$[*]') LIKE :weekday_pattern
|
|
"""
|
|
|
|
# SQLite JSON query to check if current weekday is in the array
|
|
weekday_pattern = f'%{current_weekday}%'
|
|
|
|
result = await self.db.execute(text(query), {
|
|
"current_time": current_time,
|
|
"weekday_pattern": weekday_pattern
|
|
})
|
|
|
|
reminders = []
|
|
for row in result:
|
|
days_of_week = json.loads(row.days_of_week or '[]')
|
|
if current_weekday in days_of_week:
|
|
reminders.append(HabitReminder(
|
|
habit_id=row.habit_id,
|
|
user_id=row.user_id,
|
|
reminder_time=row.reminder_time,
|
|
days_of_week=days_of_week,
|
|
is_active=row.is_active,
|
|
message_template=row.message_template or f"Time for your {row.habit_title} habit!",
|
|
advance_minutes=row.advance_minutes or 0
|
|
))
|
|
|
|
return reminders
|
|
|
|
async def send_due_reminders(self):
|
|
"""Send all due habit reminders"""
|
|
|
|
reminders = await self.get_due_reminders()
|
|
|
|
for reminder in reminders:
|
|
await self.notification_manager.send_habit_reminder(
|
|
habit_id=reminder.habit_id,
|
|
user_id=reminder.user_id,
|
|
custom_message=reminder.message_template
|
|
)
|
|
|
|
|
|
# Global instances
|
|
websocket_manager = WebSocketManager()
|
|
notification_manager = None # Initialized with database session
|
|
|
|
|
|
async def notification_scheduler():
|
|
"""Background task to process scheduled notifications and reminders"""
|
|
|
|
while True:
|
|
try:
|
|
if notification_manager:
|
|
# Process scheduled notifications
|
|
await notification_manager.process_scheduled_notifications()
|
|
|
|
# Process habit reminders
|
|
reminder_service = HabitReminderService(notification_manager.db, notification_manager)
|
|
await reminder_service.send_due_reminders()
|
|
|
|
# Ping WebSocket connections
|
|
await websocket_manager.ping_connections()
|
|
|
|
# Wait 60 seconds before next check
|
|
await asyncio.sleep(60)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in notification scheduler: {e}")
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
# FastAPI WebSocket endpoint
|
|
async def websocket_endpoint(websocket: WebSocket, user_id: int):
|
|
"""WebSocket endpoint for real-time notifications"""
|
|
|
|
try:
|
|
await websocket_manager.connect(websocket, user_id)
|
|
|
|
while True:
|
|
# Keep connection alive and handle incoming messages
|
|
try:
|
|
data = await websocket.receive_text()
|
|
message = json.loads(data)
|
|
|
|
# Handle different message types
|
|
if message.get("type") == "ping":
|
|
await websocket.send_text(json.dumps({
|
|
"type": "pong",
|
|
"timestamp": datetime.now().isoformat()
|
|
}))
|
|
elif message.get("type") == "mark_read":
|
|
if notification_manager:
|
|
await notification_manager.mark_notification_read(
|
|
message["notification_id"], user_id
|
|
)
|
|
|
|
except WebSocketDisconnect:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"WebSocket error: {e}")
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.error(f"WebSocket connection error: {e}")
|
|
|
|
finally:
|
|
websocket_manager.disconnect(websocket)
|
|
|
|
|
|
# FastAPI endpoints for notifications
|
|
async def get_notifications(user_id: int, unread_only: bool = False,
|
|
limit: int = 50, db: Session = None) -> Dict:
|
|
"""Get user notifications"""
|
|
|
|
if not notification_manager:
|
|
return {"notifications": [], "unread_count": 0}
|
|
|
|
notifications = await notification_manager.get_user_notifications(
|
|
user_id, limit, unread_only
|
|
)
|
|
unread_count = await notification_manager.get_unread_count(user_id)
|
|
|
|
return {
|
|
"notifications": notifications,
|
|
"unread_count": unread_count,
|
|
"has_more": len(notifications) == limit
|
|
}
|
|
|
|
|
|
async def mark_notification_read(notification_id: str, user_id: int, db: Session = None):
|
|
"""Mark notification as read"""
|
|
|
|
if notification_manager:
|
|
await notification_manager.mark_notification_read(notification_id, user_id)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
async def create_habit_reminder(user_id: int, reminder_data: Dict, db: Session = None) -> Dict:
|
|
"""Create a habit reminder"""
|
|
|
|
if not notification_manager:
|
|
return {"error": "Notification system not available"}
|
|
|
|
reminder_service = HabitReminderService(db, notification_manager)
|
|
reminder_id = await reminder_service.create_habit_reminder({
|
|
**reminder_data,
|
|
"user_id": user_id
|
|
})
|
|
|
|
return {"reminder_id": reminder_id, "success": True} |