Skip to content

Commit db68a49

Browse files
yywingauvipy
authored andcommitted
feat: add clocked schedule (#226)
* feat: add clocked schedule * fix: rebase master and regen migration * fix: make ci happy * style(clocked): change clocked schedule verbose name and help text * fix: regen migration
1 parent 786290c commit db68a49

File tree

7 files changed

+232
-11
lines changed

7 files changed

+232
-11
lines changed

django_celery_beat/admin.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .models import (
1717
PeriodicTask, PeriodicTasks,
1818
IntervalSchedule, CrontabSchedule,
19-
SolarSchedule
19+
SolarSchedule, ClockedSchedule
2020
)
2121
from .utils import is_database_scheduler
2222

@@ -128,7 +128,7 @@ class PeriodicTaskAdmin(admin.ModelAdmin):
128128
'classes': ('extrapretty', 'wide'),
129129
}),
130130
('Schedule', {
131-
'fields': ('interval', 'crontab', 'solar',
131+
'fields': ('interval', 'crontab', 'solar', 'clocked',
132132
'start_time', 'one_off'),
133133
'classes': ('extrapretty', 'wide'),
134134
}),
@@ -152,7 +152,7 @@ def changelist_view(self, request, extra_context=None):
152152

153153
def get_queryset(self, request):
154154
qs = super(PeriodicTaskAdmin, self).get_queryset(request)
155-
return qs.select_related('interval', 'crontab', 'solar')
155+
return qs.select_related('interval', 'crontab', 'solar', 'clocked')
156156

157157
def _message_user_about_update(self, request, rows_updated, verb):
158158
"""Send message about action to user.
@@ -236,4 +236,5 @@ def run_tasks(self, request, queryset):
236236
admin.site.register(IntervalSchedule)
237237
admin.site.register(CrontabSchedule)
238238
admin.site.register(SolarSchedule)
239+
admin.site.register(ClockedSchedule)
239240
admin.site.register(PeriodicTask, PeriodicTaskAdmin)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Clocked schedule Implementation."""
2+
from __future__ import absolute_import, unicode_literals
3+
4+
5+
from celery import schedules
6+
from celery.utils.time import maybe_make_aware
7+
from collections import namedtuple
8+
9+
10+
schedstate = namedtuple('schedstate', ('is_due', 'next'))
11+
12+
13+
class clocked(schedules.BaseSchedule):
14+
"""clocked schedule.
15+
16+
It depend on PeriodicTask once_off
17+
"""
18+
19+
def __init__(self, clocked_time, enabled=True,
20+
model=None, nowfun=None, app=None):
21+
"""Initialize clocked."""
22+
self.clocked_time = maybe_make_aware(clocked_time)
23+
self.enabled = enabled
24+
self.model = model
25+
super(clocked, self).__init__(nowfun=nowfun, app=app)
26+
27+
def remaining_estimate(self, last_run_at):
28+
return self.clocked_time - self.now()
29+
30+
def is_due(self, last_run_at):
31+
# actually last run at is useless
32+
last_run_at = maybe_make_aware(last_run_at)
33+
rem_delta = self.remaining_estimate(last_run_at)
34+
remaining_s = max(rem_delta.total_seconds(), 0)
35+
if not self.enabled:
36+
return schedstate(is_due=False, next=None)
37+
if remaining_s == 0:
38+
if self.model:
39+
self.model.enabled = False
40+
self.model.save()
41+
return schedstate(is_due=True, next=None)
42+
return schedstate(is_due=False, next=remaining_s)
43+
44+
def __repr__(self):
45+
return '<clocked: {} {}>'.format(self.clocked_time, self.enabled)
46+
47+
def __eq__(self, other):
48+
if isinstance(other, clocked):
49+
return self.clocked_time == other.clocked_time and \
50+
self.enabled == other.enabled
51+
return False
52+
53+
def __ne__(self, other):
54+
return not self.__eq__(other)
55+
56+
def __reduce__(self):
57+
return self.__class__, (self.clocked_time, self.nowfun)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 2.2 on 2019-05-08 01:53
2+
# flake8: noqa
3+
from __future__ import absolute_import, unicode_literals
4+
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('django_celery_beat', '0010_auto_20190429_0326'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='ClockedSchedule',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('clocked_time', models.DateTimeField(help_text='Run the task at clocked time', verbose_name='Clock Time')),
21+
('enabled', models.BooleanField(default=True, editable=False, help_text='Set to False to disable the schedule', verbose_name='Enabled')),
22+
],
23+
options={
24+
'verbose_name': 'clocked',
25+
'verbose_name_plural': 'clocked',
26+
'ordering': ['clocked_time'],
27+
},
28+
),
29+
migrations.AddField(
30+
model_name='periodictask',
31+
name='clocked',
32+
field=models.ForeignKey(blank=True, help_text='Clocked Schedule to run the task on. Set only one schedule type, leave the others null.', null=True, on_delete=django.db.models.deletion.CASCADE, to='django_celery_beat.ClockedSchedule', verbose_name='Clocked Schedule'),
33+
),
34+
]

django_celery_beat/models.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from . import managers, validators
1717
from .tzcrontab import TzAwareCrontab
1818
from .utils import make_aware, now
19+
from .clockedschedule import clocked
1920

2021

2122
DAYS = 'days'
@@ -166,6 +167,50 @@ def period_singular(self):
166167
return self.period[:-1]
167168

168169

170+
@python_2_unicode_compatible
171+
class ClockedSchedule(models.Model):
172+
"""clocked schedule."""
173+
174+
clocked_time = models.DateTimeField(
175+
verbose_name=_('Clock Time'),
176+
help_text=_('Run the task at clocked time'),
177+
)
178+
enabled = models.BooleanField(
179+
default=True,
180+
editable=False,
181+
verbose_name=_('Enabled'),
182+
help_text=_('Set to False to disable the schedule'),
183+
)
184+
185+
class Meta:
186+
"""Table information."""
187+
188+
verbose_name = _('clocked')
189+
verbose_name_plural = _('clocked')
190+
ordering = ['clocked_time']
191+
192+
def __str__(self):
193+
return '{} {}'.format(self.clocked_time, self.enabled)
194+
195+
@property
196+
def schedule(self):
197+
c = clocked(clocked_time=self.clocked_time,
198+
enabled=self.enabled, model=self)
199+
return c
200+
201+
@classmethod
202+
def from_schedule(cls, schedule):
203+
spec = {'clocked_time': schedule.clocked_time,
204+
'enabled': schedule.enabled}
205+
try:
206+
return cls.objects.get(**spec)
207+
except cls.DoesNotExist:
208+
return cls(**spec)
209+
except MultipleObjectsReturned:
210+
cls.objects.filter(**spec).delete()
211+
return cls(**spec)
212+
213+
169214
@python_2_unicode_compatible
170215
class CrontabSchedule(models.Model):
171216
"""Timezone Aware Crontab-like schedule.
@@ -348,7 +393,12 @@ class PeriodicTask(models.Model):
348393
help_text=_('Solar Schedule to run the task on. '
349394
'Set only one schedule type, leave the others null.'),
350395
)
351-
396+
clocked = models.ForeignKey(
397+
ClockedSchedule, on_delete=models.CASCADE, null=True, blank=True,
398+
verbose_name=_('Clocked Schedule'),
399+
help_text=_('Clocked Schedule to run the task on. '
400+
'Set only one schedule type, leave the others null.'),
401+
)
352402
# TODO: use django's JsonField
353403
args = models.TextField(
354404
blank=True, default='[]',
@@ -464,24 +514,30 @@ class Meta:
464514
def validate_unique(self, *args, **kwargs):
465515
super(PeriodicTask, self).validate_unique(*args, **kwargs)
466516

467-
schedule_types = ['interval', 'crontab', 'solar']
517+
schedule_types = ['interval', 'crontab', 'solar', 'clocked']
468518
selected_schedule_types = [s for s in schedule_types
469519
if getattr(self, s)]
470520

471521
if len(selected_schedule_types) == 0:
472522
raise ValidationError({
473523
'interval': [
474-
'One of interval, crontab, or solar must be set.'
524+
'One of clocked, interval, crontab, or solar must be set.'
475525
]
476526
})
477527

478-
err_msg = 'Only one of interval, crontab, or solar must be set'
528+
err_msg = 'Only one of clocked, interval, crontab, '\
529+
'or solar must be set'
479530
if len(selected_schedule_types) > 1:
480531
error_info = {}
481532
for selected_schedule_type in selected_schedule_types:
482533
error_info[selected_schedule_type] = [err_msg]
483534
raise ValidationError(error_info)
484535

536+
# clocked must be one off task
537+
if self.clocked and not self.one_off:
538+
err_msg = 'clocked must be one off, one_off must set True'
539+
raise ValidationError(err_msg)
540+
485541
def save(self, *args, **kwargs):
486542
self.exchange = self.exchange or None
487543
self.routing_key = self.routing_key or None
@@ -499,6 +555,8 @@ def __str__(self):
499555
fmt = '{0.name}: {0.crontab}'
500556
if self.solar:
501557
fmt = '{0.name}: {0.solar}'
558+
if self.clocked:
559+
fmt = '{0.name}: {0.clocked}'
502560
return fmt.format(self)
503561

504562
@property
@@ -509,6 +567,8 @@ def schedule(self):
509567
return self.crontab.schedule
510568
if self.solar:
511569
return self.solar.schedule
570+
if self.clocked:
571+
return self.clocked.schedule
512572

513573

514574
signals.pre_delete.connect(PeriodicTasks.changed, sender=PeriodicTask)
@@ -525,3 +585,7 @@ def schedule(self):
525585
PeriodicTasks.update_changed, sender=SolarSchedule)
526586
signals.post_save.connect(
527587
PeriodicTasks.update_changed, sender=SolarSchedule)
588+
signals.post_delete.connect(
589+
PeriodicTasks.update_changed, sender=ClockedSchedule)
590+
signals.post_save.connect(
591+
PeriodicTasks.update_changed, sender=ClockedSchedule)

django_celery_beat/schedulers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
from .models import (
2525
PeriodicTask, PeriodicTasks,
2626
CrontabSchedule, IntervalSchedule,
27-
SolarSchedule,
27+
SolarSchedule, ClockedSchedule
2828
)
2929
from .utils import make_aware
30+
from .clockedschedule import clocked
3031

3132
try:
3233
from celery.utils.time import is_naive
@@ -53,6 +54,7 @@ class ModelEntry(ScheduleEntry):
5354
(schedules.crontab, CrontabSchedule, 'crontab'),
5455
(schedules.schedule, IntervalSchedule, 'interval'),
5556
(schedules.solar, SolarSchedule, 'solar'),
57+
(clocked, ClockedSchedule, 'clocked')
5658
)
5759
save_fields = ['last_run_at', 'total_run_count', 'no_changes']
5860

t/unit/test_admin.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
PeriodicTask, \
99
CrontabSchedule, \
1010
IntervalSchedule, \
11-
SolarSchedule
11+
SolarSchedule, \
12+
ClockedSchedule
1213
from django.core.exceptions import ValidationError
1314

1415

@@ -67,6 +68,7 @@ def test_validate_unique_raises_for_multiple_schedules(self):
6768
('crontab', CrontabSchedule()),
6869
('interval', IntervalSchedule()),
6970
('solar', SolarSchedule()),
71+
('clocked', ClockedSchedule())
7072
]
7173
for options in combinations(schedules, 2):
7274
with self.assertRaises(ValidationError):
@@ -76,3 +78,4 @@ def test_validate_unique_not_raises(self):
7678
PeriodicTask(crontab=CrontabSchedule()).validate_unique()
7779
PeriodicTask(interval=IntervalSchedule()).validate_unique()
7880
PeriodicTask(solar=SolarSchedule()).validate_unique()
81+
PeriodicTask(clocked=ClockedSchedule(), one_off=True).validate_unique()

0 commit comments

Comments
 (0)