├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml └── project ├── Dockerfile ├── celerybeat-schedule ├── core ├── __init__.py ├── asgi.py ├── celery.py ├── settings.py ├── tasks.py ├── urls.py └── wsgi.py ├── entrypoint.sh ├── manage.py ├── orders ├── __init__.py ├── admin.py ├── apps.py ├── management │ └── commands │ │ ├── email_report.py │ │ └── my_custom_command.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── tests.py ├── urls.py └── views.py ├── products.json ├── requirements.txt └── templates └── orders └── order_list.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | env 4 | *.sqlite3 5 | .DS_Store 6 | staticfiles 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TestDriven.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Handling Periodic Tasks in Django with Celery and Docker 2 | 3 | Example of how to manage periodic tasks with Django, Celery, and Docker 4 | 5 | ## Want to learn how to build this? 6 | 7 | Check out the [article](https://testdriven.io/blog/django-celery-periodic-tasks/). 8 | 9 | ## Want to use this project? 10 | 11 | Spin up the containers: 12 | 13 | ```sh 14 | $ docker compose up -d --build 15 | ``` 16 | 17 | Open the logs associated with the `celery` service to see the tasks running periodically: 18 | 19 | ```sh 20 | $ docker compose logs -f 'celery' 21 | ``` 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: ./project 4 | command: python manage.py runserver 0.0.0.0:8000 5 | volumes: 6 | - ./project/:/usr/src/app/ 7 | ports: 8 | - 1337:8000 9 | environment: 10 | - DEBUG=1 11 | - SECRET_KEY=dbaa1_i7%*3r9-=z-+_mz4r-!qeed@(-a_r(g@k8jo8y3r27%m 12 | - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] 13 | depends_on: 14 | - redis 15 | redis: 16 | image: redis:alpine 17 | celery: 18 | build: ./project 19 | command: celery -A core worker -l info 20 | volumes: 21 | - ./project/:/usr/src/app/ 22 | environment: 23 | - DEBUG=1 24 | - SECRET_KEY=dbaa1_i7%*3r9-=z-+_mz4r-!qeed@(-a_r(g@k8jo8y3r27%m 25 | - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] 26 | depends_on: 27 | - redis 28 | celery-beat: 29 | build: ./project 30 | command: celery -A core beat -l info 31 | volumes: 32 | - ./project/:/usr/src/app/ 33 | environment: 34 | - DEBUG=1 35 | - SECRET_KEY=dbaa1_i7%*3r9-=z-+_mz4r-!qeed@(-a_r(g@k8jo8y3r27%m 36 | - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] 37 | depends_on: 38 | - redis 39 | -------------------------------------------------------------------------------- /project/Dockerfile: -------------------------------------------------------------------------------- 1 | # pull official base image 2 | FROM python:3.12.4-alpine 3 | 4 | # set work directory 5 | WORKDIR /usr/src/app 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # install dependencies 12 | RUN pip install --upgrade pip 13 | COPY ./requirements.txt /usr/src/app/requirements.txt 14 | RUN pip install -r requirements.txt 15 | 16 | # copy entrypoint.sh 17 | COPY ./entrypoint.sh /usr/src/app/entrypoint.sh 18 | 19 | # copy project 20 | COPY . /usr/src/app/ 21 | 22 | # run entrypoint.sh 23 | ENTRYPOINT ["/usr/src/app/entrypoint.sh"] 24 | -------------------------------------------------------------------------------- /project/celerybeat-schedule: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testdrivenio/django-celery-beat/fec7f6fdfb4d4c9f7046dc0ce46191715097bdab/project/celerybeat-schedule -------------------------------------------------------------------------------- /project/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /project/core/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for core project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /project/core/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 7 | 8 | app = Celery("core") 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | app.autodiscover_tasks() 11 | -------------------------------------------------------------------------------- /project/core/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for core project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | from celery.schedules import crontab 17 | 18 | import core.tasks 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = Path(__file__).resolve().parent.parent 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 26 | 27 | SECRET_KEY = os.environ.get("SECRET_KEY") 28 | 29 | DEBUG = int(os.environ.get("DEBUG", default=0)) 30 | 31 | # 'DJANGO_ALLOWED_HOSTS' should be a single string of hosts with a space between each. 32 | # For example: 'DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]' 33 | ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ") 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.messages", 43 | "django.contrib.staticfiles", 44 | "orders.apps.OrdersConfig", 45 | ] 46 | 47 | MIDDLEWARE = [ 48 | "django.middleware.security.SecurityMiddleware", 49 | "django.contrib.sessions.middleware.SessionMiddleware", 50 | "django.middleware.common.CommonMiddleware", 51 | "django.middleware.csrf.CsrfViewMiddleware", 52 | "django.contrib.auth.middleware.AuthenticationMiddleware", 53 | "django.contrib.messages.middleware.MessageMiddleware", 54 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 55 | ] 56 | 57 | ROOT_URLCONF = "core.urls" 58 | 59 | TEMPLATES = [ 60 | { 61 | "BACKEND": "django.template.backends.django.DjangoTemplates", 62 | "DIRS": [BASE_DIR / "templates"], 63 | "APP_DIRS": True, 64 | "OPTIONS": { 65 | "context_processors": [ 66 | "django.template.context_processors.debug", 67 | "django.template.context_processors.request", 68 | "django.contrib.auth.context_processors.auth", 69 | "django.contrib.messages.context_processors.messages", 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = "core.wsgi.application" 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 80 | 81 | DATABASES = { 82 | "default": { 83 | "ENGINE": "django.db.backends.sqlite3", 84 | "NAME": BASE_DIR / "db.sqlite3", 85 | } 86 | } 87 | 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 101 | }, 102 | { 103 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 110 | 111 | LANGUAGE_CODE = "en-us" 112 | 113 | TIME_ZONE = "UTC" 114 | 115 | USE_I18N = True 116 | 117 | USE_L10N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 124 | 125 | STATIC_URL = "/staticfiles/" 126 | STATIC_ROOT = BASE_DIR / "staticfiles" 127 | 128 | # Default primary key field type 129 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 130 | 131 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 132 | 133 | CELERY_BROKER_URL = "redis://redis:6379" 134 | CELERY_RESULT_BACKEND = "redis://redis:6379" 135 | 136 | CELERY_BEAT_SCHEDULE = { 137 | "sample_task": { 138 | "task": "core.tasks.sample_task", 139 | "schedule": crontab(minute="*/1"), 140 | }, 141 | "send_email_report": { 142 | "task": "core.tasks.send_email_report", 143 | "schedule": crontab(hour="*/1"), 144 | }, 145 | } 146 | 147 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 148 | DEFAULT_FROM_EMAIL = "noreply@email.com" 149 | ADMINS = [("testuser", "test.user@email.com"), ] 150 | -------------------------------------------------------------------------------- /project/core/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from celery.utils.log import get_task_logger 3 | from django.core.management import call_command 4 | 5 | 6 | logger = get_task_logger(__name__) 7 | 8 | 9 | @shared_task 10 | def sample_task(): 11 | logger.info("The sample task just ran.") 12 | 13 | 14 | @shared_task 15 | def send_email_report(): 16 | call_command("email_report", ) 17 | -------------------------------------------------------------------------------- /project/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("", include("orders.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /project/core/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for core project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /project/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # python manage.py flush --no-input 4 | python manage.py migrate 5 | python manage.py collectstatic --no-input --clear 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /project/orders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testdrivenio/django-celery-beat/fec7f6fdfb4d4c9f7046dc0ce46191715097bdab/project/orders/__init__.py -------------------------------------------------------------------------------- /project/orders/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Order, Product 4 | 5 | 6 | @admin.register(Order) 7 | class OrderAdmin(admin.ModelAdmin): 8 | list_display = ("id", "product", "added") 9 | 10 | 11 | @admin.register(Product) 12 | class ProductAdmin(admin.ModelAdmin): 13 | list_display = ("title", "description") 14 | -------------------------------------------------------------------------------- /project/orders/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrdersConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "orders" 7 | -------------------------------------------------------------------------------- /project/orders/management/commands/email_report.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, time, datetime 2 | 3 | from django.core.mail import mail_admins 4 | from django.core.management import BaseCommand 5 | from django.utils import timezone 6 | from django.utils.timezone import make_aware 7 | 8 | from orders.models import Order 9 | 10 | today = timezone.now() 11 | tomorrow = today + timedelta(1) 12 | today_start = make_aware(datetime.combine(today, time())) 13 | today_end = make_aware(datetime.combine(tomorrow, time())) 14 | 15 | 16 | class Command(BaseCommand): 17 | help = "Send Today's Orders Report to Admins" 18 | 19 | def handle(self, *args, **options): 20 | orders = Order.objects.filter(confirmed_date__range=(today_start, today_end)) 21 | 22 | if orders: 23 | message = "" 24 | 25 | for order in orders: 26 | message += f"{order} \n" 27 | 28 | subject = ( 29 | f"Order Report for {today_start.strftime('%Y-%m-%d')} " 30 | f"to {today_end.strftime('%Y-%m-%d')}" 31 | ) 32 | 33 | mail_admins(subject=subject, message=message, html_message=None) 34 | 35 | self.stdout.write("E-mail Report was sent.") 36 | else: 37 | self.stdout.write("No orders confirmed today.") 38 | -------------------------------------------------------------------------------- /project/orders/management/commands/my_custom_command.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | 4 | class Command(BaseCommand): 5 | help = "A description of the command" 6 | 7 | def handle(self, *args, **options): 8 | self.stdout.write("My sample command just ran.") 9 | -------------------------------------------------------------------------------- /project/orders/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.4 on 2021-07-01 02:30 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Product', 18 | fields=[ 19 | ('added', models.DateTimeField(auto_now_add=True)), 20 | ('edited', models.DateTimeField(auto_now=True)), 21 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 22 | ('title', models.CharField(max_length=200)), 23 | ('description', models.CharField(max_length=500)), 24 | ], 25 | options={ 26 | 'abstract': False, 27 | }, 28 | ), 29 | migrations.CreateModel( 30 | name='Order', 31 | fields=[ 32 | ('added', models.DateTimeField(auto_now_add=True)), 33 | ('edited', models.DateTimeField(auto_now=True)), 34 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 35 | ('confirmed_date', models.DateTimeField(blank=True, null=True)), 36 | ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='orders.product')), 37 | ], 38 | options={ 39 | 'ordering': ['-added'], 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /project/orders/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testdrivenio/django-celery-beat/fec7f6fdfb4d4c9f7046dc0ce46191715097bdab/project/orders/migrations/__init__.py -------------------------------------------------------------------------------- /project/orders/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class TimeStampedModel(models.Model): 7 | added = models.DateTimeField(auto_now_add=True) 8 | edited = models.DateTimeField(auto_now=True) 9 | 10 | class Meta: 11 | abstract = True 12 | 13 | 14 | class Product(TimeStampedModel): 15 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 16 | title = models.CharField(max_length=200, ) 17 | description = models.CharField(max_length=500, ) 18 | 19 | def __str__(self): 20 | return f"Product: {self.title}" 21 | 22 | 23 | class Order(TimeStampedModel): 24 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 25 | product = models.ForeignKey(Product, on_delete=models.PROTECT, ) 26 | confirmed_date = models.DateTimeField(null=True, blank=True) 27 | 28 | def __str__(self): 29 | return f"Order: {self.id} - product: {self.product.title}" 30 | 31 | class Meta: 32 | ordering = ["-added"] 33 | -------------------------------------------------------------------------------- /project/orders/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /project/orders/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import OrderListView 4 | 5 | app_name = "orders" 6 | 7 | urlpatterns = [ 8 | path("", OrderListView.as_view(), name="list"), 9 | ] 10 | -------------------------------------------------------------------------------- /project/orders/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.views.generic import ListView 3 | 4 | from .models import Order, Product 5 | 6 | 7 | class OrderListView(ListView): 8 | model = Order 9 | -------------------------------------------------------------------------------- /project/products.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "orders.product", 4 | "pk": "0102b1c3-fa49-4793-8292-66996506522a", 5 | "fields": { 6 | "added": "2021-06-15T09:52:03.538Z", 7 | "edited": "2021-06-15T09:52:03.538Z", 8 | "title": "Rice", 9 | "description": "Basmati rice" 10 | } 11 | }, 12 | { 13 | "model": "orders.product", 14 | "pk": "41f16514-8ab9-4ea7-b205-335ccf16a260", 15 | "fields": { 16 | "added": "2021-06-15T09:54:05.968Z", 17 | "edited": "2021-06-15T09:54:05.968Z", 18 | "title": "Baked beans", 19 | "description": "Baked beans in Tomato Sauce" 20 | } 21 | }, 22 | { 23 | "model": "orders.product", 24 | "pk": "6c51a182-0ce8-4671-a2e7-e82724da74be", 25 | "fields": { 26 | "added": "2021-06-15T09:51:49.789Z", 27 | "edited": "2021-06-15T09:51:49.789Z", 28 | "title": "Coffee", 29 | "description": "Dark roasted coffee" 30 | } 31 | }, 32 | { 33 | "model": "orders.product", 34 | "pk": "a1e4cc76-19f3-4a1b-a98c-2a31cc1dc953", 35 | "fields": { 36 | "added": "2021-06-15T09:53:28.358Z", 37 | "edited": "2021-06-15T09:53:28.358Z", 38 | "title": "Potatoes", 39 | "description": "King Edward potatoes" 40 | } 41 | } 42 | ] 43 | -------------------------------------------------------------------------------- /project/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.0.7 2 | celery==5.4.0 3 | redis==5.0.7 4 | -------------------------------------------------------------------------------- /project/templates/orders/order_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |