Search code examples
djangodjango-viewscelerydjango-celerycelery-task

sending periodic emails over django using celery tasks


I have a Group model:

class Group(models.Model):
   leader = models.ForeignKey(User, on_delete=models.CASCADE)
   name = models.CharField(max_length=55)
   description = models.TextField()
   joined = models.ManyToManyField(User, blank=True)
   start_time = models.TimeField(null=True)
   end_time = models.TimeField(null=True)
   email_list = ArrayField(
        models.CharField(max_length=255, blank=True),
        blank=True,
        default=list,
    )

and I want to send an email to all Users who have joined a particular Group 30 minutes before the start_time. For example: if a Group has a start_time of 1:00 PM, I want to send an email to all the joined Users at 12:30 PM, letting them know the group will be meeting soon.

I currently have a bunch of celery tasks that run without error, but they are all called within views by the User (creating, updating, joining, leaving, and deleting groups will trigger a celery task to send an email notification to the User).

The scheduled email I am trying to accomplish here will be a periodic task, I assume, and not in the control of the User. However, it isn't like other periodic tasks I've seen because the time it relies on is based on the start_time of a specific Group.

@Brian in the comments pointed out that it can be a regular celery task that is called by the periodic task every minute. Here's my celery task:

from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.mail import send_mail
from my_chaburah.settings import NOTIFICATION_EMAIL
from django.template.loader import render_to_string

@shared_task(name='start_group_notification_task')
def start_group_notification_task(recipients):
    logger.info('sent email to whole group that group is starting')
    for recipient in recipients:
        send_mail (
                    'group starting',
                    'group starting',
                    NOTIFICATION_EMAIL,
                    [recipient],
                    fail_silently=False
                )

I'm still not sure exactly how to call this task using a periodic task or how to query my groups and find when groups start_time == now + 30mins. I've read the docs, but I'm new to celery and celery beat and a bit confused by how to move forward.

I'm also not sure where exactly to call the task.

my myapp/celery.py file:

import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_group.settings')

app = Celery('my_group')

app.config_from_object('django.conf:settings', namespace='CELERY')

app.autodiscover_tasks()

@app.task(bind=True, ignore_result=True)
def debug_task(self):
    print(f'Request: {self.request!r}')

my group/tasks.py file:

from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.mail import send_mail
from my_chaburah.settings import NOTIFICATION_EMAIL
from django.template.loader import render_to_string

logger = get_task_logger(__name__)

I have a bunch of tasks that I didn't include, but I'm assuming any task regarding my Group model would go here. Still not sure though.

I'd also like to add the ability for the leader of the Group to be able to set the amount time prior to start_time where the email will be sent. For example: 10, mins, 30 mins, 1hr before meeting, but that's more to do with the model.


Solution

  • I was able to figure out how to run the task based on start_time but am concerned about issues with runtime.

    I added this to my celery.py file:

    app.conf.beat_schedule = {
        'start_group_notification': {
            'task': 'start_group_notification_task',
            'schedule': crontab(),
        }
    }
    

    Which runs the task every minute. The task then checks to see if a Group has a start time within 30 minutes. In group/tasks.py

    @shared_task(name='start_group_notification_task')
    def start_group_notification_task():
        logger.info('sent email to whole group that group is starting')
        thirty_minutes_from_now = datetime.datetime.now() + datetime.timedelta(minutes=30)
        groups = Group.objects.filter(
            start_time__hour=thirty_minutes_from_now.hour, 
            start_time__minute=thirty_minutes_from_now.minute
        ).prefetch_related("joined")
        for group in groups:
            for email in group.email_list:
                send_mail (
                        'group starting in 30 minutes',
                        group.name,
                        NOTIFICATION_EMAIL,
                        [email],
                        fail_silently=False
                    )
    
    

    Now, this works, but I'm concerned about having nested for loops. Is there maybe a better way to do this to have runtime be as little as possible? Or are celery tasks executed fast enough and easy enough that it's not an issue.

    As @Brian mentioned this doesn't take into account Users joining within the 30 minute period before a Group starts. The fix to this is for me to see if User joins group within that 30 min period and call a different task to tell them the group is starting soon.

    EDIT: If a User joins a Group within the 30 minute window, I added this variable and conditional:

    time_left = int(chaburah.start_time.strftime("%H%M")) - int(current_date.strftime("%H%M"))
    if  time_left <= 30 and time_left >= 0:
        celery_task.delay()
    

    That works if a User joins within the 30, but if the Group has already started I have to implement a new task to let the User know the Group has started.