# backend/modules/calendar/tasks.py import logging import asyncio from datetime import datetime, timedelta, time, timezone from celery import shared_task from celery.exceptions import Ignore from core.celery_app import celery_app from core.database import get_db from modules.calendar.models import CalendarEvent from modules.notifications.service import send_push_notification from modules.auth.models import User # Assuming user model is in modules/user/models.py logger = logging.getLogger(__name__) # Key prefix for storing scheduled task IDs in Redis (or Celery backend) SCHEDULED_TASK_KEY_PREFIX = "calendar_event_tasks:" def get_scheduled_task_key(event_id: int) -> str: return f"{SCHEDULED_TASK_KEY_PREFIX}{event_id}" @shared_task(bind=True) def schedule_event_notifications(self, event_id: int): """Schedules reminder notifications for a calendar event.""" db_gen = get_db() db = next(db_gen) try: event = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() if not event: logger.warning( f"Calendar event {event_id} not found for scheduling notifications." ) raise Ignore() # Don't retry if event doesn't exist user = db.query(User).filter(User.id == event.user_id).first() if not user or not user.expo_push_token: logger.warning( f"User {event.user_id} or their push token not found for event {event_id}. Skipping notification scheduling." ) # Cancel any potentially existing tasks for this event if user/token is now invalid cancel_event_notifications(event_id) raise Ignore() # Don't retry if user/token missing # Cancel any existing notifications for this event first cancel_event_notifications(event_id) # Run synchronously within this task scheduled_task_ids = [] now_utc = datetime.now(timezone.utc) if event.all_day: # Schedule one notification at 6:00 AM in the event's original timezone (or UTC if naive) event_start_date = event.start.date() notification_time_local = datetime.combine( event_start_date, time(6, 0), tzinfo=event.start.tzinfo ) # Convert scheduled time to UTC for Celery ETA notification_time_utc = notification_time_local.astimezone(timezone.utc) if notification_time_utc > now_utc: task = send_event_notification.apply_async( args=[event.id, user.expo_push_token, "all_day"], eta=notification_time_utc, ) scheduled_task_ids.append(task.id) logger.info( f"Scheduled all-day notification for event {event_id} at {notification_time_utc} (Task ID: {task.id})" ) else: logger.info( f"All-day notification time {notification_time_utc} for event {event_id} is in the past. Skipping." ) else: # Ensure event start time is timezone-aware (assume UTC if naive) event_start_utc = event.start if event_start_utc.tzinfo is None: event_start_utc = event_start_utc.replace(tzinfo=timezone.utc) else: event_start_utc = event_start_utc.astimezone(timezone.utc) times_before = { "1_hour": timedelta(hours=1), "30_min": timedelta(minutes=30), } for label, delta in times_before.items(): notification_time_utc = event_start_utc - delta if notification_time_utc > now_utc: task = send_event_notification.apply_async( args=[event.id, user.expo_push_token, label], eta=notification_time_utc, ) scheduled_task_ids.append(task.id) logger.info( f"Scheduled {label} notification for event {event_id} at {notification_time_utc} (Task ID: {task.id})" ) else: logger.info( f"{label} notification time {notification_time_utc} for event {event_id} is in the past. Skipping." ) # Store the new task IDs using Celery backend (Redis) if scheduled_task_ids: key = get_scheduled_task_key(event_id) # Store as a simple comma-separated string celery_app.backend.set(key, ",".join(scheduled_task_ids)) logger.debug(f"Stored task IDs for event {event_id}: {scheduled_task_ids}") except Exception as e: logger.exception(f"Error scheduling notifications for event {event_id}: {e}") # Optional: Add retry logic if appropriate # self.retry(exc=e, countdown=60) finally: next(db_gen, None) # Ensure db session is closed # Note: This task calls an async function. Ensure your Celery worker # is configured to handle async tasks (e.g., using gevent, eventlet, or uvicorn worker). @shared_task(bind=True) def send_event_notification( self, event_id: int, user_push_token: str, notification_type: str ): """Sends a single reminder notification for a calendar event.""" db_gen = get_db() db = next(db_gen) try: event = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() if not event: logger.warning( f"Calendar event {event_id} not found for sending {notification_type} notification." ) raise Ignore() # Don't retry if event doesn't exist # Double-check user and token validity at the time of sending user = db.query(User).filter(User.id == event.user_id).first() if not user or user.expo_push_token != user_push_token: logger.warning( f"User {event.user_id} token mismatch or user not found for event {event_id} at notification time. Skipping." ) raise Ignore() title = f"Upcoming: {event.title}" if notification_type == "all_day": body = f"Today: {event.title}" if event.description: body += f" - {event.description[:50]}" # Add part of description elif notification_type == "1_hour": local_start_time = event.start.astimezone().strftime( "%I:%M %p" ) # Convert to local time for display body = f"Starts at {local_start_time} (in 1 hour)" elif notification_type == "30_min": local_start_time = event.start.astimezone().strftime("%I:%M %p") body = f"Starts at {local_start_time} (in 30 mins)" else: body = "Check your calendar for details." # Fallback logger.info( f"Sending {notification_type} notification for event {event_id} to token {user_push_token[:10]}..." ) try: # Call the async notification service success = asyncio.run( send_push_notification( push_token=user_push_token, title=title, body=body, data={"eventId": event.id, "type": "calendar_reminder"}, ) ) if not success: logger.error( f"Failed to send {notification_type} notification for event {event_id} via service." ) # Optional: self.retry(countdown=60) # Retry sending if failed else: logger.info( f"Successfully sent {notification_type} notification for event {event_id}." ) except Exception as e: logger.exception( f"Error calling send_push_notification for event {event_id}: {e}" ) # Optional: self.retry(exc=e, countdown=60) except Exception as e: logger.exception( f"General error sending {notification_type} notification for event {event_id}: {e}" ) # Optional: self.retry(exc=e, countdown=60) finally: next(db_gen, None) # Ensure db session is closed # This is run synchronously when called, or can be called as a task itself # @shared_task # Uncomment if you want to call this asynchronously e.g., .delay() def cancel_event_notifications(event_id: int): """Cancels all scheduled reminder notifications for a calendar event.""" key = get_scheduled_task_key(event_id) try: task_ids_bytes = celery_app.backend.get(key) if task_ids_bytes: # Decode from bytes (assuming Redis backend) task_ids_str = task_ids_bytes.decode("utf-8") task_ids = task_ids_str.split(",") logger.info(f"Cancelling scheduled tasks for event {event_id}: {task_ids}") revoked_count = 0 for task_id in task_ids: if task_id: # Ensure not empty string try: celery_app.control.revoke( task_id.strip(), terminate=True, signal="SIGKILL" ) revoked_count += 1 except Exception as revoke_err: logger.error( f"Error revoking task {task_id} for event {event_id}: {revoke_err}" ) # Delete the key from Redis after attempting revocation celery_app.backend.delete(key) logger.debug( f"Revoked {revoked_count} tasks and removed task ID key {key} from backend for event {event_id}." ) else: logger.debug( f"No scheduled tasks found in backend to cancel for event {event_id} (key: {key})." ) except Exception as e: logger.exception(f"Error cancelling notifications for event {event_id}: {e}")