From 49d9331322f4c386fe33965e2d9ee566d1ec7575 Mon Sep 17 00:00:00 2001 From: Diego Castro Date: Mon, 11 Apr 2022 10:35:52 +0200 Subject: [PATCH 1/5] Support for abstract base models improved. (#516) Model classes moved into a `models.py` directory, which exports by default only the default `django-celery-beat` model classes now from `models.generic.py`. `managers.py` moved into `models.py` directory, because it's something that is needed only by models. --- django_celery_beat/models/__init__.py | 19 +++++ .../{models.py => models/abstract.py} | 74 +++++++--------- django_celery_beat/models/generic.py | 85 +++++++++++++++++++ django_celery_beat/{ => models}/managers.py | 0 4 files changed, 135 insertions(+), 43 deletions(-) create mode 100644 django_celery_beat/models/__init__.py rename django_celery_beat/{models.py => models/abstract.py} (91%) create mode 100644 django_celery_beat/models/generic.py rename django_celery_beat/{ => models}/managers.py (100%) diff --git a/django_celery_beat/models/__init__.py b/django_celery_beat/models/__init__.py new file mode 100644 index 00000000..d23507e7 --- /dev/null +++ b/django_celery_beat/models/__init__.py @@ -0,0 +1,19 @@ +from .generic import ( + ClockedSchedule, + ClockScheduler, + CrontabSchedule, + IntervalSchedule, + PeriodicTask, + PeriodicTasks, + SolarSchedule +) + +__ALL__ = [ + "ClockedSchedule", + "ClockScheduler", + "CrontabSchedule", + "IntervalSchedule", + "PeriodicTask", + "PeriodicTasks", + "SolarSchedule" +] \ 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..a55808e4 100644 --- a/django_celery_beat/models.py +++ b/django_celery_beat/models/abstract.py @@ -7,13 +7,12 @@ 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 ..tzcrontab import TzAwareCrontab +from ..utils import make_aware, now +from ..clockedschedule import clocked DAYS = 'days' @@ -72,8 +71,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 +100,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 +133,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 +165,7 @@ class IntervalSchedule(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('interval') verbose_name_plural = _('intervals') ordering = ['period', 'every'] @@ -206,8 +206,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 +217,7 @@ class ClockedSchedule(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('clocked') verbose_name_plural = _('clocked') ordering = ['clocked_time'] @@ -240,8 +241,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 +306,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 +356,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 +370,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 +392,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 +410,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 +550,7 @@ class PeriodicTask(models.Model): class Meta: """Table information.""" + abstract = True verbose_name = _('periodic task') verbose_name_plural = _('periodic tasks') @@ -614,24 +622,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 From b12052bc988af5df1b8036c20c5678e41c2094e8 Mon Sep 17 00:00:00 2001 From: Diego Castro Date: Tue, 10 May 2022 15:56:30 +0200 Subject: [PATCH 2/5] Fixed a wrong import for validators --- django_celery_beat/models/abstract.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/django_celery_beat/models/abstract.py b/django_celery_beat/models/abstract.py index a55808e4..de05b1f4 100644 --- a/django_celery_beat/models/abstract.py +++ b/django_celery_beat/models/abstract.py @@ -9,10 +9,11 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from . import managers, validators +from . import managers +from .. import validators +from ..clockedschedule import clocked from ..tzcrontab import TzAwareCrontab from ..utils import make_aware, now -from ..clockedschedule import clocked DAYS = 'days' From e1b41aaa4271d7aa504eff8299d53e1d4cae8417 Mon Sep 17 00:00:00 2001 From: Diego Castro Date: Tue, 10 May 2022 17:01:20 +0200 Subject: [PATCH 3/5] Exporting 'crontab_schedule_celery_timezone' from 'models.abstract' `crontab_schedule_celery_timezone` wasn't exported properly and raised an `AttributeError` when applying migrations. --- django_celery_beat/models/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/django_celery_beat/models/__init__.py b/django_celery_beat/models/__init__.py index d23507e7..b5211b76 100644 --- a/django_celery_beat/models/__init__.py +++ b/django_celery_beat/models/__init__.py @@ -5,7 +5,8 @@ IntervalSchedule, PeriodicTask, PeriodicTasks, - SolarSchedule + SolarSchedule, + crontab_schedule_celery_timezone, ) __ALL__ = [ @@ -15,5 +16,6 @@ "IntervalSchedule", "PeriodicTask", "PeriodicTasks", - "SolarSchedule" + "SolarSchedule", + "crontab_schedule_celery_timezone", ] \ No newline at end of file From a0a7b1394774e4644d7f7929c37f032e94d5fc26 Mon Sep 17 00:00:00 2001 From: Diego Castro Date: Tue, 10 May 2022 17:25:23 +0200 Subject: [PATCH 4/5] Fixed a wrong export introduced in prev commit --- django_celery_beat/models/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_celery_beat/models/__init__.py b/django_celery_beat/models/__init__.py index b5211b76..02fa2943 100644 --- a/django_celery_beat/models/__init__.py +++ b/django_celery_beat/models/__init__.py @@ -1,3 +1,4 @@ +from .abstract import crontab_schedule_celery_timezone from .generic import ( ClockedSchedule, ClockScheduler, @@ -6,7 +7,6 @@ PeriodicTask, PeriodicTasks, SolarSchedule, - crontab_schedule_celery_timezone, ) __ALL__ = [ From 742ee5472e5b8020662a71664f43e78524d88192 Mon Sep 17 00:00:00 2001 From: Diego Castro Date: Fri, 3 Jun 2022 11:08:30 +0200 Subject: [PATCH 5/5] Bypass de default models used by `django_celery_beat.scheduler` (#516) when `CELERY_BEAT_(?:PERIODICTASKS?|(?:CRONTAB|INTERVAL|SOLAR|CLOCKED)SCHEDULE)_MODEL` constants are defined in django `settings`. Providing the `app_label.model_name` of your own models as value for the constants `CELERY_BEAT_(?:PERIODICTASKS?|(?:CRONTAB|INTERVAL|SOLAR|CLOCKED)SCHEDULE)_MODEL` will let `django_celery_beat.scheduler` use the custom models instead of the default ones (aka generic models, in this context/pull-request): ```python CELERY_BEAT_PERIODICTASK_MODEL = "app_label.model_name" CELERY_BEAT_PERIODICTASKS_MODEL = "app_label.model_name" CELERY_BEAT_CRONTABSCHEDULE_MODEL = "app_label.model_name" CELERY_BEAT_INTERVALSCHEDULE_MODEL = "app_label.model_name" CELERY_BEAT_SOLARSCHEDULE_MODEL = "app_label.model_name" CELERY_BEAT_CLOCKEDSCHEDULE_MODEL = "app_label.model_name" ``` Doing this we add support to automatically bypass the default `django_celery_beat` models without forcing developers to overwrite the whole `django_celery_beat.scheduler` in projects where the default models doesn't fit the requirements I updated the `README.rst` with a small explanation about how this work Additonal information: * related issue: #516 * pull-request: #534 --- .gitignore | 1 + README.rst | 48 +++++++++++ django_celery_beat/helpers.py | 133 +++++++++++++++++++++++++++++++ django_celery_beat/schedulers.py | 20 +++-- 4 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 django_celery_beat/helpers.py 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/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."""