diff --git a/scheduler/models.py b/scheduler/models.py index 44b2026..75cbe47 100644 --- a/scheduler/models.py +++ b/scheduler/models.py @@ -26,18 +26,23 @@ class BaseJobArg(models.Model): ('int_val', _('int')), ('datetime_val', _('Datetime')), ) - str_val = models.CharField(_('String Value'), blank=True, max_length=255) - int_val = models.IntegerField(_('Int Value'), blank=True, null=True) - datetime_val = models.DateTimeField(_('Datetime Value'), blank=True, null=True) - arg_name = models.CharField( _('Argument Type'), max_length=12, choices=ARG_NAME, default=ARG_NAME.str_val ) + str_val = models.CharField(_('String Value'), blank=True, max_length=255) + int_val = models.IntegerField(_('Int Value'), blank=True, null=True) + datetime_val = models.DateTimeField(_('Datetime Value'), blank=True, null=True) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey() + def __repr__(self): + return repr(self.value()) + + def __str__(self): + return str(self.value()) + def clean(self): self.clean_one_value() @@ -57,6 +62,9 @@ def clean_one_value(self): _('There are multiple arg types with values'), code='invalid') }) + def value(self): + return getattr(self, self.arg_name) + class Meta: abstract = True @@ -68,14 +76,21 @@ class JobArg(BaseJobArg): class JobKwarg(BaseJobArg): key = models.CharField(max_length=255) + def __str__(self): + key, value = self.value() + return 'key={} value={}'.format(key, value) + + def value(self): + return self.key, super(JobKwarg, self).value() + @python_2_unicode_compatible class BaseJob(TimeStampedModel): name = models.CharField(_('name'), max_length=128, unique=True) callable = models.CharField(_('callable'), max_length=2048) - callable_args = GenericRelation(JobArg) - callable_kwargs = GenericRelation(JobKwarg) + callable_args = GenericRelation(JobArg, related_query_name='args') + callable_kwargs = GenericRelation(JobKwarg, related_query_name='kwargs') enabled = models.BooleanField(_('enabled'), default=True) queue = models.CharField(_('queue'), max_length=16) job_id = models.CharField( @@ -160,12 +175,12 @@ def unschedule(self): return True def parse_args(self): - args = self.callable_args.values().order_by('id') - return [arg[arg['arg_name']] for arg in args] + args = self.callable_args.all().order_by('id') + return [arg.value() for arg in args] def parse_kwargs(self): - kwargs = self.callable_kwargs.values().order_by('id') - return {kwarg['key']: kwarg[kwarg['arg_name']] for kwarg in kwargs} + kwargs = self.callable_kwargs.all().order_by('id') + return dict([kwarg.value() for kwarg in kwargs]) def function_string(self): func = self.callable + "(\u200b{})" # zero-width space allows textwrap @@ -198,13 +213,15 @@ class ScheduledJob(ScheduledTimeMixin, BaseJob): def schedule(self): if self.is_schedulable() is False: return False - kwargs = {} + kwargs = self.parse_kwargs() if self.timeout: kwargs['timeout'] = self.timeout if self.result_ttl is not None: kwargs['job_result_ttl'] = self.result_ttl job = self.scheduler().enqueue_at( - self.schedule_time_utc(), self.callable_func(), + self.schedule_time_utc(), + self.callable_func(), + *self.parse_args(), **kwargs ) self.job_id = job.id diff --git a/scheduler/tests.py b/scheduler/tests.py index 6f5fad3..0ec1743 100644 --- a/scheduler/tests.py +++ b/scheduler/tests.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.test import TestCase from django.utils import timezone @@ -12,14 +13,12 @@ import pytz import django_rq from django_rq import job -from scheduler.models import BaseJob, CronJob, RepeatableJob, ScheduledJob +from scheduler.models import BaseJob, CronJob, RepeatableJob, ScheduledJob,\ + BaseJobArg, JobArg, JobKwarg import django_rq.queues class BaseJobFactory(factory.DjangoModelFactory): - class Meta: - django_get_or_create = ('name',) - name = factory.Sequence(lambda n: 'Scheduled Job %d' % n) queue = list(settings.RQ_QUEUES.keys())[0] callable = 'scheduler.tests.test_job' @@ -27,22 +26,23 @@ class Meta: timeout = None # job_id = None - -class ScheduledJobFactory(BaseJobFactory): class Meta: - model = ScheduledJob + django_get_or_create = ('name',) + abstract = True + +class ScheduledJobFactory(BaseJobFactory): result_ttl = None @factory.lazy_attribute def scheduled_time(self): return timezone.now() + timedelta(days=1) - -class RepeatableJobFactory(BaseJobFactory): class Meta: - model = RepeatableJob + model = ScheduledJob + +class RepeatableJobFactory(BaseJobFactory): result_ttl = None interval = 1 interval_unit = 'hours' @@ -52,13 +52,43 @@ class Meta: def scheduled_time(self): return timezone.now() + timedelta(minutes=1) + class Meta: + model = RepeatableJob + class CronJobFactory(BaseJobFactory): + cron_string = "0 0 * * *" + repeat = None + class Meta: model = CronJob - cron_string = "0 0 * * *" - repeat = None + +class BaseJobArgFactory(factory.DjangoModelFactory): + arg_name = 'str_val' + str_val = '' + int_val = None + datetime_val = None + object_id = factory.SelfAttribute('content_object.id') + content_type = factory.LazyAttribute( + lambda o: ContentType.objects.get_for_model(o.content_object)) + content_object = factory.SubFactory(ScheduledJobFactory) + + class Meta: + exclude = ['content_object'] + abstract = True + + +class JobArgFactory(BaseJobArgFactory): + class Meta: + model = JobArg + + +class JobKwargFactory(BaseJobArgFactory): + key = factory.Sequence(lambda n: 'key%d' % n) + + class Meta: + model = JobKwarg @job @@ -68,25 +98,55 @@ def test_job(): @job def test_args_kwargs(*args, **kwargs): - func = "test_args_kwargs({0}, {1})" + func = "test_args_kwargs({})" args_list = [repr(arg) for arg in args] - arg_string = ', '.join(args_list) - kwarg_string = ', '.join([k + '=' + repr(kwargs[k]) for k in kwargs]) - return func.format(arg_string, kwarg_string) + kwargs_list = [k + '=' + repr(v) for (k, v) in kwargs.items()] + return func.format(', '.join(args_list + kwargs_list)) + test_non_callable = 'I am a teapot' class BaseTestCases: + class TestBaseJobArg(TestCase): + JobArgClass = BaseJobArg + JobArgClassFactory = BaseJobArgFactory + + def test_clean_one_value_empty(self): + arg = self.JobArgClassFactory() + with self.assertRaises(ValidationError): + arg.clean_one_value() + + def test_clean_one_value_invalid_str_int(self): + arg = self.JobArgClassFactory(str_val='not blank', int_val=1, datetime_val=None) + with self.assertRaises(ValidationError): + arg.clean_one_value() + + def test_clean_one_value_invalid_str_datetime(self): + arg = self.JobArgClassFactory(str_val='not blank', int_val=None, datetime_val=timezone.now()) + with self.assertRaises(ValidationError): + arg.clean_one_value() + + def test_clean_one_value_invalid_int_datetime(self): + arg = self.JobArgClassFactory(str_val= '', int_val=1, datetime_val=timezone.now()) + with self.assertRaises(ValidationError): + arg.clean_one_value() + + def test_clean_invalid(self): + arg = self.JobArgClassFactory(str_val='str', int_val=1, datetime_val=timezone.now()) + with self.assertRaises(ValidationError): + arg.clean() + + def test_clean(self): + arg = self.JobArgClassFactory(str_val='something') + self.assertIsNone(arg.clean()) + + class TestBaseJob(TestCase): def setUp(self): django_rq.queues.get_redis_connection = lambda _, strict: FakeStrictRedis() - # Turn on synchronous mode to execute jobs immediately instead of passing them off to the workers - for config in settings.RQ_QUEUES.values(): - config["ASYNC"] = False - # Never runs as BaseJob but helps IDE auto-completes JobClass = BaseJob JobClassFactory = BaseJobFactory @@ -227,10 +287,63 @@ def test_timeout_passthroug(self): entry = next(i for i in scheduler.get_jobs() if i.id == job.job_id) self.assertEqual(entry.timeout, 500) + def test_parse_args(self): + job = self.JobClassFactory() + date = timezone.now() + JobArgFactory(str_val='one', content_object=job) + JobArgFactory(arg_name='int_val', int_val=2, content_object=job) + JobArgFactory(arg_name='datetime_val', datetime_val=date, content_object=job) + self.assertEqual(job.parse_args(), ['one', 2, date]) + + def test_parse_kwargs(self): + job = self.JobClassFactory() + date = timezone.now() + JobKwargFactory(key='key1', arg_name='str_val', str_val='one', content_object=job) + JobKwargFactory(key='key2', arg_name='int_val', int_val=2, content_object=job) + JobKwargFactory(key='key3', arg_name='datetime_val', datetime_val=date, content_object=job) + self.assertEqual(job.parse_kwargs(), dict(key1='one', key2=2, key3=date)) + + def test_function_string(self): + job = self.JobClassFactory() + date = datetime(2018, 1, 1) + JobArgFactory(arg_name='str_val', str_val='one', content_object=job) + JobArgFactory(arg_name='int_val', int_val=1, content_object=job) + JobArgFactory(arg_name='datetime_val', datetime_val=date, content_object=job) + JobKwargFactory(key='key1', arg_name='str_val', str_val='one', content_object=job) + JobKwargFactory(key='key2', arg_name='int_val', int_val=2, content_object=job) + JobKwargFactory(key='key3', arg_name='datetime_val', datetime_val=date, content_object=job) + self.assertEqual(job.function_string(), + ("scheduler.tests.test_job(\u200b'one', 1, datetime.datetime(2018, 1, 1, 0, 0), " + + "key1='one', key2=2, key3=datetime.datetime(2018, 1, 1, 0, 0))")) + + def test_callable_result(self): + job = self.JobClassFactory() + scheduler = django_rq.get_scheduler(job.queue) + entry = next(i for i in scheduler.get_jobs() if i.id == job.job_id) + self.assertEqual(entry.perform(), 2) + + def test_callable_empty_args_and_kwagrgs(self): + job = self.JobClassFactory(callable='scheduler.tests.test_args_kwargs') + scheduler = django_rq.get_scheduler(job.queue) + entry = next(i for i in scheduler.get_jobs() if i.id == job.job_id) + self.assertEqual(entry.perform(), 'test_args_kwargs()') + + def test_callable_args_and_kwagrgs(self): + job = self.JobClassFactory(callable='scheduler.tests.test_args_kwargs') + date = datetime(2018, 1, 1) + JobArgFactory(arg_name='str_val', str_val='one', content_object=job) + JobKwargFactory(key='key1', arg_name='int_val', int_val=2, content_object=job) + JobKwargFactory(key='key2', arg_name='datetime_val', datetime_val=date, content_object=job) + job.save() + scheduler = django_rq.get_scheduler(job.queue) + entry = next(i for i in scheduler.get_jobs() if i.id == job.job_id) + self.assertEqual(entry.perform(), + "test_args_kwargs('one', key1=2, key2=datetime.datetime(2018, 1, 1, 0, 0))") + class TestSchedulableJob(TestBaseJob): # Currently ScheduledJob and RepeatableJob - JobClass = ScheduledJob - JobClassFactory = ScheduledJobFactory + JobClass = BaseJob + JobClassFactory = BaseJobFactory def test_schedule_time_utc(self): job = self.JobClass() @@ -248,6 +361,76 @@ def test_result_ttl_passthroug(self): self.assertEqual(entry.result_ttl, 500) +class TestJobArg(BaseTestCases.TestBaseJobArg): + JobArgClass = JobArg + JobArgClassFactory = JobArgFactory + + def test_value(self): + arg = self.JobArgClassFactory(arg_name='str_val', str_val='something') + self.assertEqual(arg.value(), 'something') + + def test__str__str_val(self): + arg = self.JobArgClassFactory(arg_name='str_val', str_val='something') + self.assertEqual('something', str(arg)) + + def test__str__int_val(self): + arg = self.JobArgClassFactory(arg_name='int_val', int_val=1) + self.assertEqual('1', str(arg)) + + def test__str__datetime_val(self): + time = datetime(2018, 1, 1) + arg = self.JobArgClassFactory(arg_name='datetime_val', datetime_val=time) + self.assertEqual('2018-01-01 00:00:00', str(arg)) + + def test__repr__str_val(self): + arg = self.JobArgClassFactory(arg_name='str_val', str_val='something') + self.assertEqual("'something'", repr(arg)) + + def test__repr__int_val(self): + arg = self.JobArgClassFactory(arg_name='int_val', int_val=1) + self.assertEqual('1', repr(arg)) + + def test__repr__datetime_val(self): + time = datetime(2018, 1, 1, 0, 0) + arg = self.JobArgClassFactory(arg_name='datetime_val', datetime_val=time) + self.assertEqual('datetime.datetime(2018, 1, 1, 0, 0)', repr(arg)) + + +class TestJobKwarg(BaseTestCases.TestBaseJobArg): + JobArgClass = JobKwarg + JobArgClassFactory = JobKwargFactory + + def test_value(self): + kwarg = self.JobArgClassFactory(key='key', arg_name='str_val', str_val='value') + self.assertEqual(kwarg.value(), ('key', 'value')) + + def test__str__str_val(self): + kwarg = self.JobArgClassFactory(key='key1', arg_name='str_val', str_val='something') + self.assertEqual("key=key1 value=something", str(kwarg)) + + def test__str__int_val(self): + kwarg = self.JobArgClassFactory(key='key1', arg_name='int_val', int_val=1) + self.assertEqual("key=key1 value=1", str(kwarg)) + + def test__str__datetime_val(self): + time = datetime(2018, 1, 1) + kwarg = self.JobArgClassFactory(key='key1', arg_name='datetime_val', datetime_val=time) + self.assertEqual("key=key1 value=2018-01-01 00:00:00", str(kwarg)) + + def test__repr__str_val(self): + kwarg = self.JobArgClassFactory(key='key', arg_name='str_val', str_val='something') + self.assertEqual("('key', 'something')", repr(kwarg)) + + def test__repr__int_val(self): + kwarg = self.JobArgClassFactory(key='key', arg_name='int_val', int_val=1) + self.assertEqual("('key', 1)", repr(kwarg)) + + def test__repr__datetime_val(self): + time = datetime(2018, 1, 1, 0, 0) + kwarg = self.JobArgClassFactory(key='key', arg_name='datetime_val', datetime_val=time) + self.assertEqual("('key', datetime.datetime(2018, 1, 1, 0, 0))", repr(kwarg)) + + class TestScheduledJob(BaseTestCases.TestSchedulableJob): JobClass = ScheduledJob JobClassFactory = ScheduledJobFactory