Files
bakery-ia/services/notification/app/repositories/preference_repository.py

474 lines
18 KiB
Python
Raw Normal View History

2025-08-08 09:08:41 +02:00
"""
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}")