474 lines
18 KiB
Python
474 lines
18 KiB
Python
|
|
"""
|
||
|
|
Preference Repository
|
||
|
|
Repository for notification preference operations
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import Optional, List, Dict, Any
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
from sqlalchemy import select, text, and_
|
||
|
|
from datetime import datetime
|
||
|
|
import structlog
|
||
|
|
|
||
|
|
from .base import NotificationBaseRepository
|
||
|
|
from app.models.notifications import NotificationPreference
|
||
|
|
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
|
||
|
|
|
||
|
|
logger = structlog.get_logger()
|
||
|
|
|
||
|
|
|
||
|
|
class PreferenceRepository(NotificationBaseRepository):
|
||
|
|
"""Repository for notification preference operations"""
|
||
|
|
|
||
|
|
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 900):
|
||
|
|
# Preferences are relatively stable, medium cache time (15 minutes)
|
||
|
|
super().__init__(NotificationPreference, session, cache_ttl)
|
||
|
|
|
||
|
|
async def create_preferences(self, preference_data: Dict[str, Any]) -> NotificationPreference:
|
||
|
|
"""Create user notification preferences with validation"""
|
||
|
|
try:
|
||
|
|
# Validate preference data
|
||
|
|
validation_result = self._validate_notification_data(
|
||
|
|
preference_data,
|
||
|
|
["user_id", "tenant_id"]
|
||
|
|
)
|
||
|
|
|
||
|
|
if not validation_result["is_valid"]:
|
||
|
|
raise ValidationError(f"Invalid preference data: {validation_result['errors']}")
|
||
|
|
|
||
|
|
# Check if preferences already exist for this user and tenant
|
||
|
|
existing_prefs = await self.get_user_preferences(
|
||
|
|
preference_data["user_id"],
|
||
|
|
preference_data["tenant_id"]
|
||
|
|
)
|
||
|
|
|
||
|
|
if existing_prefs:
|
||
|
|
raise DuplicateRecordError(f"Preferences already exist for user in this tenant")
|
||
|
|
|
||
|
|
# Set default values
|
||
|
|
defaults = {
|
||
|
|
"email_enabled": True,
|
||
|
|
"email_alerts": True,
|
||
|
|
"email_marketing": False,
|
||
|
|
"email_reports": True,
|
||
|
|
"whatsapp_enabled": False,
|
||
|
|
"whatsapp_alerts": False,
|
||
|
|
"whatsapp_reports": False,
|
||
|
|
"push_enabled": True,
|
||
|
|
"push_alerts": True,
|
||
|
|
"push_reports": False,
|
||
|
|
"quiet_hours_start": "22:00",
|
||
|
|
"quiet_hours_end": "08:00",
|
||
|
|
"timezone": "Europe/Madrid",
|
||
|
|
"digest_frequency": "daily",
|
||
|
|
"max_emails_per_day": 10,
|
||
|
|
"language": "es"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Apply defaults for any missing fields
|
||
|
|
for key, default_value in defaults.items():
|
||
|
|
if key not in preference_data:
|
||
|
|
preference_data[key] = default_value
|
||
|
|
|
||
|
|
# Create preferences
|
||
|
|
preferences = await self.create(preference_data)
|
||
|
|
|
||
|
|
logger.info("User notification preferences created",
|
||
|
|
preferences_id=preferences.id,
|
||
|
|
user_id=preferences.user_id,
|
||
|
|
tenant_id=preferences.tenant_id)
|
||
|
|
|
||
|
|
return preferences
|
||
|
|
|
||
|
|
except (ValidationError, DuplicateRecordError):
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to create preferences",
|
||
|
|
user_id=preference_data.get("user_id"),
|
||
|
|
tenant_id=preference_data.get("tenant_id"),
|
||
|
|
error=str(e))
|
||
|
|
raise DatabaseError(f"Failed to create preferences: {str(e)}")
|
||
|
|
|
||
|
|
async def get_user_preferences(
|
||
|
|
self,
|
||
|
|
user_id: str,
|
||
|
|
tenant_id: str
|
||
|
|
) -> Optional[NotificationPreference]:
|
||
|
|
"""Get notification preferences for a specific user and tenant"""
|
||
|
|
try:
|
||
|
|
preferences = await self.get_multi(
|
||
|
|
filters={
|
||
|
|
"user_id": user_id,
|
||
|
|
"tenant_id": tenant_id
|
||
|
|
},
|
||
|
|
limit=1
|
||
|
|
)
|
||
|
|
return preferences[0] if preferences else None
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get user preferences",
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
error=str(e))
|
||
|
|
raise DatabaseError(f"Failed to get preferences: {str(e)}")
|
||
|
|
|
||
|
|
async def update_user_preferences(
|
||
|
|
self,
|
||
|
|
user_id: str,
|
||
|
|
tenant_id: str,
|
||
|
|
update_data: Dict[str, Any]
|
||
|
|
) -> Optional[NotificationPreference]:
|
||
|
|
"""Update user notification preferences"""
|
||
|
|
try:
|
||
|
|
preferences = await self.get_user_preferences(user_id, tenant_id)
|
||
|
|
if not preferences:
|
||
|
|
# Create preferences if they don't exist
|
||
|
|
create_data = {
|
||
|
|
"user_id": user_id,
|
||
|
|
"tenant_id": tenant_id,
|
||
|
|
**update_data
|
||
|
|
}
|
||
|
|
return await self.create_preferences(create_data)
|
||
|
|
|
||
|
|
# Validate specific preference fields
|
||
|
|
self._validate_preference_updates(update_data)
|
||
|
|
|
||
|
|
updated_preferences = await self.update(str(preferences.id), update_data)
|
||
|
|
|
||
|
|
logger.info("User preferences updated",
|
||
|
|
preferences_id=preferences.id,
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
updated_fields=list(update_data.keys()))
|
||
|
|
|
||
|
|
return updated_preferences
|
||
|
|
|
||
|
|
except ValidationError:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to update user preferences",
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
error=str(e))
|
||
|
|
raise DatabaseError(f"Failed to update preferences: {str(e)}")
|
||
|
|
|
||
|
|
async def get_users_with_email_enabled(
|
||
|
|
self,
|
||
|
|
tenant_id: str,
|
||
|
|
notification_category: str = "alerts"
|
||
|
|
) -> List[NotificationPreference]:
|
||
|
|
"""Get users who have email notifications enabled for a category"""
|
||
|
|
try:
|
||
|
|
filters = {
|
||
|
|
"tenant_id": tenant_id,
|
||
|
|
"email_enabled": True
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add category-specific filter
|
||
|
|
if notification_category == "alerts":
|
||
|
|
filters["email_alerts"] = True
|
||
|
|
elif notification_category == "marketing":
|
||
|
|
filters["email_marketing"] = True
|
||
|
|
elif notification_category == "reports":
|
||
|
|
filters["email_reports"] = True
|
||
|
|
|
||
|
|
return await self.get_multi(filters=filters)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get users with email enabled",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
category=notification_category,
|
||
|
|
error=str(e))
|
||
|
|
return []
|
||
|
|
|
||
|
|
async def get_users_with_whatsapp_enabled(
|
||
|
|
self,
|
||
|
|
tenant_id: str,
|
||
|
|
notification_category: str = "alerts"
|
||
|
|
) -> List[NotificationPreference]:
|
||
|
|
"""Get users who have WhatsApp notifications enabled for a category"""
|
||
|
|
try:
|
||
|
|
filters = {
|
||
|
|
"tenant_id": tenant_id,
|
||
|
|
"whatsapp_enabled": True
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add category-specific filter
|
||
|
|
if notification_category == "alerts":
|
||
|
|
filters["whatsapp_alerts"] = True
|
||
|
|
elif notification_category == "reports":
|
||
|
|
filters["whatsapp_reports"] = True
|
||
|
|
|
||
|
|
return await self.get_multi(filters=filters)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get users with WhatsApp enabled",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
category=notification_category,
|
||
|
|
error=str(e))
|
||
|
|
return []
|
||
|
|
|
||
|
|
async def get_users_with_push_enabled(
|
||
|
|
self,
|
||
|
|
tenant_id: str,
|
||
|
|
notification_category: str = "alerts"
|
||
|
|
) -> List[NotificationPreference]:
|
||
|
|
"""Get users who have push notifications enabled for a category"""
|
||
|
|
try:
|
||
|
|
filters = {
|
||
|
|
"tenant_id": tenant_id,
|
||
|
|
"push_enabled": True
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add category-specific filter
|
||
|
|
if notification_category == "alerts":
|
||
|
|
filters["push_alerts"] = True
|
||
|
|
elif notification_category == "reports":
|
||
|
|
filters["push_reports"] = True
|
||
|
|
|
||
|
|
return await self.get_multi(filters=filters)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get users with push enabled",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
category=notification_category,
|
||
|
|
error=str(e))
|
||
|
|
return []
|
||
|
|
|
||
|
|
async def check_quiet_hours(
|
||
|
|
self,
|
||
|
|
user_id: str,
|
||
|
|
tenant_id: str,
|
||
|
|
check_time: datetime = None
|
||
|
|
) -> bool:
|
||
|
|
"""Check if current time is within user's quiet hours"""
|
||
|
|
try:
|
||
|
|
preferences = await self.get_user_preferences(user_id, tenant_id)
|
||
|
|
if not preferences:
|
||
|
|
return False # No quiet hours if no preferences
|
||
|
|
|
||
|
|
if not check_time:
|
||
|
|
check_time = datetime.utcnow()
|
||
|
|
|
||
|
|
# Convert time to user's timezone (simplified - using hour comparison)
|
||
|
|
current_hour = check_time.hour
|
||
|
|
quiet_start = int(preferences.quiet_hours_start.split(":")[0])
|
||
|
|
quiet_end = int(preferences.quiet_hours_end.split(":")[0])
|
||
|
|
|
||
|
|
# Handle quiet hours that span midnight
|
||
|
|
if quiet_start > quiet_end:
|
||
|
|
return current_hour >= quiet_start or current_hour < quiet_end
|
||
|
|
else:
|
||
|
|
return quiet_start <= current_hour < quiet_end
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to check quiet hours",
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
error=str(e))
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def get_users_for_digest(
|
||
|
|
self,
|
||
|
|
tenant_id: str,
|
||
|
|
frequency: str = "daily"
|
||
|
|
) -> List[NotificationPreference]:
|
||
|
|
"""Get users who want digest notifications for a frequency"""
|
||
|
|
try:
|
||
|
|
return await self.get_multi(
|
||
|
|
filters={
|
||
|
|
"tenant_id": tenant_id,
|
||
|
|
"digest_frequency": frequency,
|
||
|
|
"email_enabled": True
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get users for digest",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
frequency=frequency,
|
||
|
|
error=str(e))
|
||
|
|
return []
|
||
|
|
|
||
|
|
async def can_send_email(
|
||
|
|
self,
|
||
|
|
user_id: str,
|
||
|
|
tenant_id: str,
|
||
|
|
category: str = "alerts"
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""Check if an email can be sent to a user based on their preferences"""
|
||
|
|
try:
|
||
|
|
preferences = await self.get_user_preferences(user_id, tenant_id)
|
||
|
|
if not preferences:
|
||
|
|
return {
|
||
|
|
"can_send": True, # Default to allowing if no preferences set
|
||
|
|
"reason": "No preferences found, using defaults"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check if email is enabled
|
||
|
|
if not preferences.email_enabled:
|
||
|
|
return {
|
||
|
|
"can_send": False,
|
||
|
|
"reason": "Email notifications disabled"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check category-specific settings
|
||
|
|
category_enabled = True
|
||
|
|
if category == "alerts" and not preferences.email_alerts:
|
||
|
|
category_enabled = False
|
||
|
|
elif category == "marketing" and not preferences.email_marketing:
|
||
|
|
category_enabled = False
|
||
|
|
elif category == "reports" and not preferences.email_reports:
|
||
|
|
category_enabled = False
|
||
|
|
|
||
|
|
if not category_enabled:
|
||
|
|
return {
|
||
|
|
"can_send": False,
|
||
|
|
"reason": f"Email {category} notifications disabled"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check quiet hours
|
||
|
|
if self.check_quiet_hours(user_id, tenant_id):
|
||
|
|
return {
|
||
|
|
"can_send": False,
|
||
|
|
"reason": "Within user's quiet hours"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check daily limit (simplified - would need to query recent notifications)
|
||
|
|
# For now, just return the limit info
|
||
|
|
return {
|
||
|
|
"can_send": True,
|
||
|
|
"max_daily_emails": preferences.max_emails_per_day,
|
||
|
|
"language": preferences.language,
|
||
|
|
"timezone": preferences.timezone
|
||
|
|
}
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to check if email can be sent",
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
error=str(e))
|
||
|
|
return {
|
||
|
|
"can_send": True, # Default to allowing on error
|
||
|
|
"reason": "Error checking preferences"
|
||
|
|
}
|
||
|
|
|
||
|
|
async def bulk_update_preferences(
|
||
|
|
self,
|
||
|
|
tenant_id: str,
|
||
|
|
update_data: Dict[str, Any],
|
||
|
|
user_ids: List[str] = None
|
||
|
|
) -> int:
|
||
|
|
"""Bulk update preferences for multiple users"""
|
||
|
|
try:
|
||
|
|
conditions = ["tenant_id = :tenant_id"]
|
||
|
|
params = {"tenant_id": tenant_id}
|
||
|
|
|
||
|
|
if user_ids:
|
||
|
|
placeholders = ", ".join([f":user_id_{i}" for i in range(len(user_ids))])
|
||
|
|
conditions.append(f"user_id IN ({placeholders})")
|
||
|
|
for i, user_id in enumerate(user_ids):
|
||
|
|
params[f"user_id_{i}"] = user_id
|
||
|
|
|
||
|
|
# Build update clause
|
||
|
|
update_fields = []
|
||
|
|
for key, value in update_data.items():
|
||
|
|
update_fields.append(f"{key} = :update_{key}")
|
||
|
|
params[f"update_{key}"] = value
|
||
|
|
|
||
|
|
params["updated_at"] = datetime.utcnow()
|
||
|
|
update_fields.append("updated_at = :updated_at")
|
||
|
|
|
||
|
|
query_text = f"""
|
||
|
|
UPDATE notification_preferences
|
||
|
|
SET {', '.join(update_fields)}
|
||
|
|
WHERE {' AND '.join(conditions)}
|
||
|
|
"""
|
||
|
|
|
||
|
|
result = await self.session.execute(text(query_text), params)
|
||
|
|
updated_count = result.rowcount
|
||
|
|
|
||
|
|
logger.info("Bulk preferences update completed",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
updated_count=updated_count,
|
||
|
|
updated_fields=list(update_data.keys()))
|
||
|
|
|
||
|
|
return updated_count
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to bulk update preferences",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
error=str(e))
|
||
|
|
raise DatabaseError(f"Bulk update failed: {str(e)}")
|
||
|
|
|
||
|
|
async def delete_user_preferences(
|
||
|
|
self,
|
||
|
|
user_id: str,
|
||
|
|
tenant_id: str
|
||
|
|
) -> bool:
|
||
|
|
"""Delete user preferences (when user leaves tenant)"""
|
||
|
|
try:
|
||
|
|
preferences = await self.get_user_preferences(user_id, tenant_id)
|
||
|
|
if not preferences:
|
||
|
|
return False
|
||
|
|
|
||
|
|
await self.delete(str(preferences.id))
|
||
|
|
|
||
|
|
logger.info("User preferences deleted",
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id)
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to delete user preferences",
|
||
|
|
user_id=user_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
error=str(e))
|
||
|
|
raise DatabaseError(f"Failed to delete preferences: {str(e)}")
|
||
|
|
|
||
|
|
def _validate_preference_updates(self, update_data: Dict[str, Any]) -> None:
|
||
|
|
"""Validate preference update data"""
|
||
|
|
# Validate boolean fields
|
||
|
|
boolean_fields = [
|
||
|
|
"email_enabled", "email_alerts", "email_marketing", "email_reports",
|
||
|
|
"whatsapp_enabled", "whatsapp_alerts", "whatsapp_reports",
|
||
|
|
"push_enabled", "push_alerts", "push_reports"
|
||
|
|
]
|
||
|
|
|
||
|
|
for field in boolean_fields:
|
||
|
|
if field in update_data and not isinstance(update_data[field], bool):
|
||
|
|
raise ValidationError(f"{field} must be a boolean value")
|
||
|
|
|
||
|
|
# Validate time format for quiet hours
|
||
|
|
time_fields = ["quiet_hours_start", "quiet_hours_end"]
|
||
|
|
for field in time_fields:
|
||
|
|
if field in update_data:
|
||
|
|
time_value = update_data[field]
|
||
|
|
if not isinstance(time_value, str) or len(time_value) != 5 or ":" not in time_value:
|
||
|
|
raise ValidationError(f"{field} must be in HH:MM format")
|
||
|
|
|
||
|
|
try:
|
||
|
|
hour, minute = time_value.split(":")
|
||
|
|
hour, minute = int(hour), int(minute)
|
||
|
|
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
||
|
|
raise ValueError()
|
||
|
|
except ValueError:
|
||
|
|
raise ValidationError(f"{field} must be a valid time in HH:MM format")
|
||
|
|
|
||
|
|
# Validate digest frequency
|
||
|
|
if "digest_frequency" in update_data:
|
||
|
|
valid_frequencies = ["none", "daily", "weekly"]
|
||
|
|
if update_data["digest_frequency"] not in valid_frequencies:
|
||
|
|
raise ValidationError(f"digest_frequency must be one of: {valid_frequencies}")
|
||
|
|
|
||
|
|
# Validate max emails per day
|
||
|
|
if "max_emails_per_day" in update_data:
|
||
|
|
max_emails = update_data["max_emails_per_day"]
|
||
|
|
if not isinstance(max_emails, int) or max_emails < 0 or max_emails > 100:
|
||
|
|
raise ValidationError("max_emails_per_day must be an integer between 0 and 100")
|
||
|
|
|
||
|
|
# Validate language
|
||
|
|
if "language" in update_data:
|
||
|
|
valid_languages = ["es", "en", "fr", "de"]
|
||
|
|
if update_data["language"] not in valid_languages:
|
||
|
|
raise ValidationError(f"language must be one of: {valid_languages}")
|