diff --git a/.gitignore b/.gitignore index b9774de3..7dcba8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ coverage.xml .python-version venv .env +.vscode/ diff --git a/README.rst b/README.rst index dbe7517a..c03f918c 100644 --- a/README.rst +++ b/README.rst @@ -86,6 +86,54 @@ manually: >>> from django_celery_beat.models import PeriodicTasks >>> PeriodicTasks.update_changed() +Custom Models +============= + +It's possible to use your own models instead of the default ones provided by ``django_celery_beat``, to do that just define your models inheriting from the right one from ``django_celery_beat.models.abstract``: + +.. code-block:: Python + + # custom_app.models.py + from django_celery_beat.models.abstract import ( + AbstractClockedSchedule, + AbstractCrontabSchedule, + AbstractIntervalSchedule, + AbstractPeriodicTask, + AbstractPeriodicTasks, + AbstractSolarSchedule, + ) + + class CustomPeriodicTask(AbstractPeriodicTask): + ... + + class CustomPeriodicTasks(AbstractPeriodicTasks): + ... + + class CustomCrontabSchedule(AbstractCrontabSchedule): + ... + + class CustomIntervalSchedule(AbstractIntervalSchedule): + ... + + class CustomSolarSchedule(AbstractSolarSchedule): + ... + + class CustomClockedSchedule(AbstractClockedSchedule): + ... + +To let ``django_celery_beat.scheduler`` make use of your own modules, you must provide the ``app_name.model_name`` of your own custom models as values to the next constants in your settings: + +.. code-block:: Python + + # settings.py + # CELERY_BEAT_(?:PERIODICTASKS?|(?:CRONTAB|INTERVAL|SOLAR|CLOCKED)SCHEDULE)_MODEL = "app_label.model_name" + CELERY_BEAT_PERIODICTASK_MODEL = "custom_app.CustomPeriodicTask" + CELERY_BEAT_PERIODICTASKS_MODEL = "custom_app.CustomPeriodicTasks" + CELERY_BEAT_CRONTABSCHEDULE_MODEL = "custom_app.CustomCrontabSchedule" + CELERY_BEAT_INTERVALSCHEDULE_MODEL = "custom_app.CustomIntervalSchedule" + CELERY_BEAT_SOLARSCHEDULE_MODEL = "custom_app.CustomSolarSchedule" + CELERY_BEAT_CLOCKEDSCHEDULE_MODEL = "custom_app.CustomClockedSchedule" + Example creating interval-based periodic task --------------------------------------------- diff --git a/django_celery_beat/helpers.py b/django_celery_beat/helpers.py new file mode 100644 index 00000000..4f468150 --- /dev/null +++ b/django_celery_beat/helpers.py @@ -0,0 +1,133 @@ +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from .models import ( + PeriodicTask, PeriodicTasks, + CrontabSchedule, IntervalSchedule, + SolarSchedule, ClockedSchedule +) + +def crontabschedule_model(): + """Return the CrontabSchedule model that is active in this project.""" + if not hasattr(settings, 'CELERY_BEAT_CRONTABSCHEDULE_MODEL'): + return CrontabSchedule + + try: + return apps.get_model( + settings.CELERY_BEAT_CRONTABSCHEDULE_MODEL + ) + except ValueError: + raise ImproperlyConfigured( + "CELERY_BEAT_CRONTABSCHEDULE_MODEL must be of the form " + "'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "CELERY_BEAT_CRONTABSCHEDULE_MODEL refers to model " + f"'{settings.CELERY_BEAT_CRONTABSCHEDULE_MODEL}' that has not " + "been installed" + ) + +def intervalschedule_model(): + """Return the IntervalSchedule model that is active in this project.""" + if not hasattr(settings, 'CELERY_BEAT_INTERVALSCHEDULE_MODEL'): + return IntervalSchedule + + try: + return apps.get_model( + settings.CELERY_BEAT_INTERVALSCHEDULE_MODEL + ) + except ValueError: + raise ImproperlyConfigured( + "CELERY_BEAT_INTERVALSCHEDULE_MODEL must be of the form " + "'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "CELERY_BEAT_INTERVALSCHEDULE_MODEL refers to model " + f"'{settings.CELERY_BEAT_INTERVALSCHEDULE_MODEL}' that has not " + "been installed" + ) + +def periodictask_model(): + """Return the PeriodicTask model that is active in this project.""" + if not hasattr(settings, 'CELERY_BEAT_PERIODICTASK_MODEL'): + return PeriodicTask + + try: + return apps.get_model(settings.CELERY_BEAT_PERIODICTASK_MODEL) + except ValueError: + raise ImproperlyConfigured( + "CELERY_BEAT_PERIODICTASK_MODEL must be of the form " + "'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "CELERY_BEAT_PERIODICTASK_MODEL refers to model " + f"'{settings.CELERY_BEAT_PERIODICTASK_MODEL}' that has not been " + "installed" + ) + +def periodictasks_model(): + """Return the PeriodicTasks model that is active in this project.""" + if not hasattr(settings, 'CELERY_BEAT_PERIODICTASKS_MODEL'): + return PeriodicTasks + + try: + return apps.get_model( + settings.CELERY_BEAT_PERIODICTASKS_MODEL + ) + except ValueError: + raise ImproperlyConfigured( + "CELERY_BEAT_PERIODICTASKS_MODEL must be of the form " + "'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "CELERY_BEAT_PERIODICTASKS_MODEL refers to model " + f"'{settings.CELERY_BEAT_PERIODICTASKS_MODEL}' that has not been " + "installed" + ) + +def solarschedule_model(): + """Return the SolarSchedule model that is active in this project.""" + if not hasattr(settings, 'CELERY_BEAT_SOLARSCHEDULE_MODEL'): + return SolarSchedule + + try: + return apps.get_model( + settings.CELERY_BEAT_SOLARSCHEDULE_MODEL + ) + except ValueError: + raise ImproperlyConfigured( + "CELERY_BEAT_SOLARSCHEDULE_MODEL must be of the form " + "'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "CELERY_BEAT_SOLARSCHEDULE_MODEL refers to model " + f"'{settings.CELERY_BEAT_SOLARSCHEDULE_MODEL}' that has not been " + "installed" + ) + +def clockedschedule_model(): + """Return the ClockedSchedule model that is active in this project.""" + if not hasattr(settings, 'CELERY_BEAT_CLOCKEDSCHEDULE_MODEL'): + return ClockedSchedule + + try: + return apps.get_model( + settings.CELERY_BEAT_CLOCKEDSCHEDULE_MODEL + ) + except ValueError: + raise ImproperlyConfigured( + "CELERY_BEAT_CLOCKEDSCHEDULE_MODEL must be of the form " + "'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "CELERY_BEAT_CLOCKEDSCHEDULE_MODEL refers to model " + f"'{settings.CELERY_BEAT_CLOCKEDSCHEDULE_MODEL}' that has not " + "been installed" + ) diff --git a/django_celery_beat/models/__init__.py b/django_celery_beat/models/__init__.py new file mode 100644 index 00000000..02fa2943 --- /dev/null +++ b/django_celery_beat/models/__init__.py @@ -0,0 +1,21 @@ +from .abstract import crontab_schedule_celery_timezone +from .generic import ( + ClockedSchedule, + ClockScheduler, + CrontabSchedule, + IntervalSchedule, + PeriodicTask, + PeriodicTasks, + SolarSchedule, +) + +__ALL__ = [ + "ClockedSchedule", + "ClockScheduler", + "CrontabSchedule", + "IntervalSchedule", + "PeriodicTask", + "PeriodicTasks", + "SolarSchedule", + "crontab_schedule_celery_timezone", +] \ No newline at end of file diff --git a/django_celery_beat/models.py b/django_celery_beat/models/abstract.py similarity index 91% rename from django_celery_beat/models.py rename to django_celery_beat/models/abstract.py index 1f87bbc5..de05b1f4 100644 --- a/django_celery_beat/models.py +++ b/django_celery_beat/models/abstract.py @@ -7,13 +7,13 @@ from django.core.exceptions import MultipleObjectsReturned, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import signals from django.utils.translation import gettext_lazy as _ -from . import managers, validators -from .tzcrontab import TzAwareCrontab -from .utils import make_aware, now -from .clockedschedule import clocked +from . import managers +from .. import validators +from ..clockedschedule import clocked +from ..tzcrontab import TzAwareCrontab +from ..utils import make_aware, now DAYS = 'days' @@ -72,8 +72,8 @@ def crontab_schedule_celery_timezone(): ] else 'UTC' -class SolarSchedule(models.Model): - """Schedule following astronomical patterns. +class AbstractSolarSchedule(models.Model): + """Abstract schedule following astronomical patterns. Example: to run every sunrise in New York City: @@ -101,6 +101,7 @@ class SolarSchedule(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('solar event') verbose_name_plural = _('solar events') ordering = ('event', 'latitude', 'longitude') @@ -133,9 +134,8 @@ def __str__(self): self.longitude ) - -class IntervalSchedule(models.Model): - """Schedule executing on a regular interval. +class AbstractIntervalSchedule(models.Model): + """Abstract schedule executing on a regular interval. Example: execute every 2 days: @@ -166,6 +166,7 @@ class IntervalSchedule(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('interval') verbose_name_plural = _('intervals') ordering = ['period', 'every'] @@ -206,8 +207,8 @@ def period_singular(self): return self.period[:-1] -class ClockedSchedule(models.Model): - """clocked schedule.""" +class AbstractClockedSchedule(models.Model): + """Abstract clocked schedule.""" clocked_time = models.DateTimeField( verbose_name=_('Clock Time'), @@ -217,6 +218,7 @@ class ClockedSchedule(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('clocked') verbose_name_plural = _('clocked') ordering = ['clocked_time'] @@ -240,8 +242,8 @@ def from_schedule(cls, schedule): return cls.objects.filter(**spec).first() -class CrontabSchedule(models.Model): - """Timezone Aware Crontab-like schedule. +class AbstractCrontabSchedule(models.Model): + """Abstract timezone Aware Crontab-like schedule. Example: Run every hour at 0 minutes for days of month 10-15: @@ -305,6 +307,7 @@ class CrontabSchedule(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('crontab') verbose_name_plural = _('crontabs') ordering = ['month_of_year', 'day_of_month', @@ -354,8 +357,8 @@ def from_schedule(cls, schedule): return cls.objects.filter(**spec).first() -class PeriodicTasks(models.Model): - """Helper table for tracking updates to periodic tasks. +class AbstractPeriodicTasks(models.Model): + """Abstract helper table for tracking updates to periodic tasks. This stores a single row with ``ident=1``. ``last_update`` is updated via django signals whenever anything is changed in the :class:`~.PeriodicTask` model. @@ -368,6 +371,12 @@ class PeriodicTasks(models.Model): objects = managers.ExtendedManager() + class Meta: + """Table information.""" + + abstract = True + + @classmethod def changed(cls, instance, **kwargs): if not instance.no_changes: @@ -384,9 +393,8 @@ def last_change(cls): except cls.DoesNotExist: pass - -class PeriodicTask(models.Model): - """Model representing a periodic task.""" +class AbstractPeriodicTask(models.Model): + """Abstract model representing a periodic task.""" name = models.CharField( max_length=200, unique=True, @@ -403,25 +411,25 @@ class PeriodicTask(models.Model): # You can only set ONE of the following schedule FK's # TODO: Redo this as a GenericForeignKey interval = models.ForeignKey( - IntervalSchedule, on_delete=models.CASCADE, + "IntervalSchedule", on_delete=models.CASCADE, null=True, blank=True, verbose_name=_('Interval Schedule'), help_text=_('Interval Schedule to run the task on. ' 'Set only one schedule type, leave the others null.'), ) crontab = models.ForeignKey( - CrontabSchedule, on_delete=models.CASCADE, null=True, blank=True, + "CrontabSchedule", on_delete=models.CASCADE, null=True, blank=True, verbose_name=_('Crontab Schedule'), help_text=_('Crontab Schedule to run the task on. ' 'Set only one schedule type, leave the others null.'), ) solar = models.ForeignKey( - SolarSchedule, on_delete=models.CASCADE, null=True, blank=True, + "SolarSchedule", on_delete=models.CASCADE, null=True, blank=True, verbose_name=_('Solar Schedule'), help_text=_('Solar Schedule to run the task on. ' 'Set only one schedule type, leave the others null.'), ) clocked = models.ForeignKey( - ClockedSchedule, on_delete=models.CASCADE, null=True, blank=True, + "ClockedSchedule", on_delete=models.CASCADE, null=True, blank=True, verbose_name=_('Clocked Schedule'), help_text=_('Clocked Schedule to run the task on. ' 'Set only one schedule type, leave the others null.'), @@ -543,6 +551,7 @@ class PeriodicTask(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('periodic task') verbose_name_plural = _('periodic tasks') @@ -614,24 +623,4 @@ def schedule(self): if self.solar: return self.solar.schedule if self.clocked: - return self.clocked.schedule - - -signals.pre_delete.connect(PeriodicTasks.changed, sender=PeriodicTask) -signals.pre_save.connect(PeriodicTasks.changed, sender=PeriodicTask) -signals.pre_delete.connect( - PeriodicTasks.update_changed, sender=IntervalSchedule) -signals.post_save.connect( - PeriodicTasks.update_changed, sender=IntervalSchedule) -signals.post_delete.connect( - PeriodicTasks.update_changed, sender=CrontabSchedule) -signals.post_save.connect( - PeriodicTasks.update_changed, sender=CrontabSchedule) -signals.post_delete.connect( - PeriodicTasks.update_changed, sender=SolarSchedule) -signals.post_save.connect( - PeriodicTasks.update_changed, sender=SolarSchedule) -signals.post_delete.connect( - PeriodicTasks.update_changed, sender=ClockedSchedule) -signals.post_save.connect( - PeriodicTasks.update_changed, sender=ClockedSchedule) + return self.clocked.schedule \ No newline at end of file diff --git a/django_celery_beat/models/generic.py b/django_celery_beat/models/generic.py new file mode 100644 index 00000000..be443541 --- /dev/null +++ b/django_celery_beat/models/generic.py @@ -0,0 +1,85 @@ +from django.db.models import signals + +from .abstract import ( + AbstractClockedSchedule, + AbstractCrontabSchedule, + AbstractIntervalSchedule, + AbstractPeriodicTask, + AbstractPeriodicTasks, + AbstractSolarSchedule +) + +class SolarSchedule(AbstractSolarSchedule): + """Schedule following astronomical patterns.""" + + class Meta(AbstractSolarSchedule.Meta): + """Table information.""" + + abstract = False + +class IntervalSchedule(AbstractIntervalSchedule): + """Schedule with a fixed interval.""" + + class Meta(AbstractIntervalSchedule.Meta): + """Table information.""" + + abstract = False + + +class ClockScheduler(AbstractClockedSchedule): + """Schedule with a fixed interval.""" + + class Meta(AbstractClockedSchedule.Meta): + """Table information.""" + + abstract = False + +class ClockedSchedule(AbstractClockedSchedule): + """Schedule with a fixed interval.""" + + class Meta(AbstractClockedSchedule.Meta): + """Table information.""" + + abstract = False + +class CrontabSchedule(AbstractCrontabSchedule): + """Schedule with cron-style syntax.""" + + class Meta(AbstractCrontabSchedule.Meta): + """Table information.""" + + abstract = False + +class PeriodicTask(AbstractPeriodicTask): + """Interal task scheduling class.""" + + class Meta(AbstractPeriodicTask.Meta): + """Table information.""" + + abstract = False + +class PeriodicTasks(AbstractPeriodicTasks): + """Helper table for tracking updates to periodic tasks.""" + + class Meta(AbstractPeriodicTasks.Meta): + abstract = False + + +signals.pre_delete.connect(PeriodicTasks.changed, sender=PeriodicTask) +signals.pre_save.connect(PeriodicTasks.changed, sender=PeriodicTask) +signals.pre_delete.connect( + PeriodicTasks.update_changed, sender=IntervalSchedule) +signals.post_save.connect( + PeriodicTasks.update_changed, sender=IntervalSchedule) +signals.post_delete.connect( + PeriodicTasks.update_changed, sender=CrontabSchedule) +signals.post_save.connect( + PeriodicTasks.update_changed, sender=CrontabSchedule) +signals.post_delete.connect( + PeriodicTasks.update_changed, sender=SolarSchedule) +signals.post_save.connect( + PeriodicTasks.update_changed, sender=SolarSchedule) +signals.post_delete.connect( + PeriodicTasks.update_changed, sender=ClockedSchedule) +signals.post_save.connect( + PeriodicTasks.update_changed, sender=ClockedSchedule) \ No newline at end of file diff --git a/django_celery_beat/managers.py b/django_celery_beat/models/managers.py similarity index 100% rename from django_celery_beat/managers.py rename to django_celery_beat/models/managers.py diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index 1a509e0d..391ac2bb 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -19,12 +19,15 @@ from django.db.utils import DatabaseError, InterfaceError from django.core.exceptions import ObjectDoesNotExist -from .models import ( - PeriodicTask, PeriodicTasks, - CrontabSchedule, IntervalSchedule, - SolarSchedule, ClockedSchedule -) from .clockedschedule import clocked +from .helpers import ( + clockedschedule_model, + crontabschedule_model, + intervalschedule_model, + periodictask_model, + periodictasks_model, + solarschedule_model, +) from .utils import NEVER_CHECK_TIMEOUT # This scheduler must wake up more frequently than the @@ -39,6 +42,13 @@ logger = get_logger(__name__) debug, info, warning = logger.debug, logger.info, logger.warning +ClockedSchedule = clockedschedule_model() +CrontabSchedule = crontabschedule_model() +IntervalSchedule = intervalschedule_model() +PeriodicTask = periodictask_model() +PeriodicTasks = periodictasks_model() +SolarSchedule = solarschedule_model() + class ModelEntry(ScheduleEntry): """Scheduler entry taken from database row."""