Ok, thanks to comments by @SergeyPugach I've done the following:
Added a post_save signal that calls a function that adds a task to the celery. apply_async let's you pass eta - estimated time of arrival which can accept DateTimeField directly, that's very convenient.
# models.py
from django.db.models import signals
from django.db import models
from .tasks import send_notification
class Notification(models.Model):
    ...
    notify_on = models.DateTimeField()
def notification_post_save(instance, *args, **kwargs):
    send_notification.apply_async((instance,), eta=instance.notify_on)
signals.post_save.connect(notification_post_save, sender=Notification)
And the actual task in the tasks.py
import logging
from user_api.celery import app
from django.core.mail import send_mail
from django.template.loader import render_to_string
@app.task
def send_notification(self, instance):
    try:
        mail_subject = 'Your notification.'
        message = render_to_string('notify.html', {
            'title': instance.title,
            'content': instance.content
        })
        send_mail(mail_subject, message, recipient_list=[instance.user.email], from_email=None)
    except instance.DoesNotExist:
        logging.warning("Notification does not exist anymore")
I will not get into details of setting up celery, there's plenty of information out there.
Now I will try to figure out how to update the task after it's notification instance was updated, but that's a completely different story.