diff --git a/django_future_tasks/management/commands/populate_periodic_future_tasks.py b/django_future_tasks/management/commands/populate_periodic_future_tasks.py index bf69350..c3680c7 100644 --- a/django_future_tasks/management/commands/populate_periodic_future_tasks.py +++ b/django_future_tasks/management/commands/populate_periodic_future_tasks.py @@ -56,7 +56,7 @@ def handle_tick(self): p_task.max_number_of_executions is not None and self.number_of_corresponding_single_tasks(p_task) >= p_task.max_number_of_executions - ): + ) or (p_task.end_time is not None and p_task.end_time < dt): p_task.is_active = False break diff --git a/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py b/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py new file mode 100644 index 0000000..09bf190 --- /dev/null +++ b/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.22 on 2023-10-20 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_future_tasks", "0005_periodicfuturetask_max_number_of_executions"), + ] + + operations = [ + migrations.AddField( + model_name="periodicfuturetask", + name="end_time", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Executions until" + ), + ), + migrations.AddConstraint( + model_name="periodicfuturetask", + constraint=models.CheckConstraint( + check=models.Q( + ("end_time__isnull", True), + ("max_number_of_executions__isnull", True), + _connector="OR", + ), + name="not_both_not_null", + ), + ), + ] diff --git a/django_future_tasks/models.py b/django_future_tasks/models.py index 0b40aee..d5e4e08 100644 --- a/django_future_tasks/models.py +++ b/django_future_tasks/models.py @@ -10,9 +10,11 @@ ) from cronfield.models import CronField from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models -from django.db.models import JSONField +from django.db.models import JSONField, Q from django.utils.dateformat import format +from django.utils.timezone import utc from django.utils.translation import gettext_lazy as _ @@ -86,6 +88,7 @@ class PeriodicFutureTask(models.Model): max_number_of_executions = models.IntegerField( _("Maximal number of executions"), null=True, blank=True ) + end_time = models.DateTimeField(_("Executions until"), null=True, blank=True) __original_is_active = None last_task_creation = models.DateTimeField( _("Last single task creation"), @@ -94,18 +97,31 @@ class PeriodicFutureTask(models.Model): ) def next_planned_execution(self): - if not self.is_active or ( - self.max_number_of_executions is not None - and FutureTask.objects.filter(periodic_parent_task=self.pk).count() - >= self.max_number_of_executions + now = datetime.datetime.now() + next_planned_execution = utc.localize( + croniter.croniter(self.cron_string, now).get_next(datetime.datetime) + ) + if ( + not self.is_active + or ( + self.max_number_of_executions is not None + and FutureTask.objects.filter(periodic_parent_task=self.pk).count() + >= self.max_number_of_executions + ) + or ( + self.end_time is not None + and self.end_time + < utc.localize( + croniter.croniter(self.cron_string, now).get_next(datetime.datetime) + ) + ) ): return None - else: - now = datetime.datetime.now() - return format( - croniter.croniter(self.cron_string, now).get_next(datetime.datetime), - settings.DATETIME_FORMAT, - ) + + return format( + next_planned_execution, + settings.DATETIME_FORMAT, + ) def cron_humnan_readable(self): descriptor = ExpressionDescriptor( @@ -125,8 +141,31 @@ def save( if self.is_active and not self.__original_is_active: self.last_task_creation = datetime.datetime.now() + self.clean() super().save() self.__original_is_active = self.is_active def __str__(self): return f"{self.periodic_task_id} ({self.cron_string})" + + def clean(self): + if self.end_time is not None and self.max_number_of_executions is not None: + raise ValidationError( + { + "end_time": _( + "Cannot be set together with maximal number of executions, at least one must be empty." + ), + "max_number_of_executions": _( + "Cannot be set together with execution end time, at least one must be empty." + ), + } + ) + + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(end_time__isnull=True) + | Q(max_number_of_executions__isnull=True), + name="not_both_not_null", + ) + ] diff --git a/tests/testapp/tests/test_periodic_future_tasks.py b/tests/testapp/tests/test_periodic_future_tasks.py index 59a960c..6879db4 100644 --- a/tests/testapp/tests/test_periodic_future_tasks.py +++ b/tests/testapp/tests/test_periodic_future_tasks.py @@ -1,6 +1,7 @@ import time from datetime import timedelta +from django.core.exceptions import ValidationError from django.test import TransactionTestCase from django.utils import timezone @@ -8,14 +9,15 @@ from tests.core import settings from tests.testapp.mixins import PopulatePeriodicTaskCommandMixin -SLEEP_TIME = 1.5 +SLEEP_TIME = 1.8 class TestPeriodicFutureTasks(PopulatePeriodicTaskCommandMixin, TransactionTestCase): def setUp(self): super().setUp() - self.original_last_task_creation = timezone.now() - timedelta(hours=2) + now = timezone.now() + self.original_last_task_creation = now - timedelta(hours=2) self.task_active = PeriodicFutureTask.objects.create( periodic_task_id="periodic task", @@ -45,6 +47,24 @@ def setUp(self): p_task.last_task_creation = self.original_last_task_creation p_task.save() + end_time = now - timedelta(hours=1) + self.task_with_end_time = PeriodicFutureTask.objects.create( + periodic_task_id="periodic task with end time", + type=settings.FUTURE_TASK_TYPE_ONE, + cron_string="42 * * * *", + end_time=end_time, + ) + + p_task = PeriodicFutureTask.objects.get(pk=self.task_with_end_time.pk) + p_task.last_task_creation = self.original_last_task_creation + p_task.save() + + self.task_for_validation_test = PeriodicFutureTask.objects.create( + periodic_task_id="periodic task for validation test", + type=settings.FUTURE_TASK_TYPE_ONE, + cron_string="42 * * * *", + ) + def test_periodic_future_task_populate_active_task(self): p_task = PeriodicFutureTask.objects.get(pk=self.task_active.pk) @@ -96,3 +116,21 @@ def test_periodic_future_task_max_one_exection(self): FutureTask.objects.filter(periodic_parent_task_id=p_task.pk).count(), 1 ) self.assertFalse(p_task.is_active) + + def test_periodic_future_task_end_time(self): + p_task = PeriodicFutureTask.objects.get(pk=self.task_with_end_time.pk) + + # Make sure that task population has been processed. + time.sleep(SLEEP_TIME) + + p_task.refresh_from_db() + self.assertEqual( + FutureTask.objects.filter(periodic_parent_task_id=p_task.pk).count(), 1 + ) + self.assertFalse(p_task.is_active) + + def test_end_time_and_max_number_of_executions_validation(self): + p_task = PeriodicFutureTask.objects.get(pk=self.task_for_validation_test.pk) + p_task.max_number_of_executions = 42 + p_task.end_time = timezone.now() + self.assertRaises(ValidationError, p_task.save)