diff --git a/README.md b/README.md index 0a41f5c..5c04278 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,17 @@ def morning_routine(): return f"15 seconds passed !" ``` +Schedule's cron can also be set to `manual` in which case it never runs, but can only be triggered manually from the admin : +```python +from django_toosimple_q.decorators import register_task, schedule_task + +# A schedule that only runs when manually triggered +@schedule_task(cron="manual") +@register_task() +def for_special_occasions(): + return f"this was triggered manually !" +``` + ### Management comment Besides standard django management commands arguments, the management command supports following arguments. @@ -361,6 +372,7 @@ pre-commit install - feature: added workerstatus to the admin, allowing to monitor workers - feature: queue tasks for later (`mytask.queue(due=now()+timedelta(hours=2))`) - feature: assign queues to schedules (`@schedule_task(queue="schedules")`) + - feature: allow manual schedules that are only run manually through the admin (`@schedule_task(cron="manual")`) - refactor: removed non-execution related data from the database (clarifying the fact tha the source of truth is the registry) - refactor: better support for concurrent workers - refactor: better names for models and decorators diff --git a/django_toosimple_q/admin.py b/django_toosimple_q/admin.py index 2091ba1..ec8dca6 100644 --- a/django_toosimple_q/admin.py +++ b/django_toosimple_q/admin.py @@ -1,6 +1,3 @@ -from datetime import datetime - -from croniter import croniter from django.contrib import admin from django.contrib.messages.constants import SUCCESS from django.template.defaultfilters import truncatechars @@ -191,14 +188,17 @@ def last_due_(self, obj): @admin.display() def next_due_(self, obj): - if obj.next_dues: - next_due = obj.next_dues[0] + if len(obj.past_dues) >= 1: + next_due = obj.past_dues[0] else: - next_due = croniter(obj.schedule.cron, timezone.now()).get_next(datetime) + next_due = obj.upcomming_due + + if next_due is None: + return "never" formatted_next_due = short_naturaltime(next_due) - if len(obj.next_dues) > 1: - formatted_next_due += mark_safe(f" [×{len(obj.next_dues)}]") + if len(obj.past_dues) > 1: + formatted_next_due += mark_safe(f" [×{len(obj.past_dues)}]") if next_due < timezone.now(): return mark_safe(f"{formatted_next_due}") return formatted_next_due diff --git a/django_toosimple_q/models.py b/django_toosimple_q/models.py index 12d19bb..d16e6e2 100644 --- a/django_toosimple_q/models.py +++ b/django_toosimple_q/models.py @@ -6,6 +6,7 @@ from croniter import croniter, croniter_range from django.db import models +from django.utils import timezone from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -202,7 +203,11 @@ def icon(self): return ScheduleExec.States.icon(self.state) @cached_property - def next_dues(self): + def past_dues(self): + if self.schedule.cron == "manual": + # A manual schedule is never due + return [] + if self.last_due is None: # If the schedule has no last due date (probaby create with run_on_creation), we run it return [croniter(self.schedule.cron, now()).get_prev(datetime)] @@ -217,14 +222,22 @@ def next_dues(self): return dues + @cached_property + def upcomming_due(self): + if self.schedule.cron == "manual": + # A manual schedule is never due + return None + + return croniter(self.schedule.cron, timezone.now()).get_next(datetime) + def execute(self): did_something = False - if self.next_dues: - logger.info(f"{self} is due ({len(self.next_dues)} occurences)") - self.schedule.execute(self.next_dues) + if self.past_dues: + logger.info(f"{self} is due ({len(self.past_dues)} occurences)") + self.last_task = self.schedule.execute(self.past_dues) did_something = True - self.last_due = self.next_dues[-1] + self.last_due = self.past_dues[-1] self.state = ScheduleExec.States.ACTIVE self.save() diff --git a/django_toosimple_q/schedule.py b/django_toosimple_q/schedule.py index a246cbd..c092daa 100644 --- a/django_toosimple_q/schedule.py +++ b/django_toosimple_q/schedule.py @@ -35,6 +35,7 @@ def execute(self, dues: List[Optional[datetime]]): """Enqueues the related tasks at the given due dates""" # We enqueue the due tasks + last_task = None for due in dues: logger.debug(f"{self} is due at {due}") @@ -42,9 +43,10 @@ def execute(self, dues: List[Optional[datetime]]): if self.datetime_kwarg: dt_kwarg = {self.datetime_kwarg: due} - tasks_registry[self.name].enqueue( + last_task = tasks_registry[self.name].enqueue( *self.args, due=due, **dt_kwarg, **self.kwargs ) + return last_task def __str__(self): return f"Schedule {self.name}" diff --git a/django_toosimple_q/tests/demo/tasks.py b/django_toosimple_q/tests/demo/tasks.py index dfc9b91..ea80561 100644 --- a/django_toosimple_q/tests/demo/tasks.py +++ b/django_toosimple_q/tests/demo/tasks.py @@ -52,7 +52,7 @@ def long_running(): return text -@schedule_task(cron="0 */30 * * * *", run_on_creation=True, queue="demo") +@schedule_task(cron="manual", queue="demo") @register_task(name="cleanup", queue="demo", priority=-5) def cleanup(): old_tasks_execs = TaskExec.objects.filter( diff --git a/django_toosimple_q/tests/tests_admin.py b/django_toosimple_q/tests/tests_admin.py index 0afba61..ef7b9a3 100644 --- a/django_toosimple_q/tests/tests_admin.py +++ b/django_toosimple_q/tests/tests_admin.py @@ -43,6 +43,34 @@ def a(): ) self.assertEqual(response.status_code, 200) + def test_manual_schedule_admin(self): + """Check that manual schedule admin action work""" + + @schedule_task(cron="manual") + @register_task(name="a") + def a(): + return 2 + + self.assertSchedule("a", None) + management.call_command("worker", "--until_done") + self.assertQueue(0) + + data = { + "action": "action_force_run", + "_selected_action": ScheduleExec.objects.get(name="a").pk, + } + response = self.client.post( + "/admin/toosimpleq/scheduleexec/", data, follow=True + ) + self.assertEqual(response.status_code, 200) + + self.assertQueue(1, state=TaskExec.States.QUEUED) + + management.call_command("worker", "--until_done") + + self.assertQueue(1, state=TaskExec.States.SUCCEEDED) + self.assertSchedule("a", ScheduleExec.States.ACTIVE) + def test_schedule_admin_force_action(self): """Check if he force execute schedule action works""" diff --git a/django_toosimple_q/tests/tests_schedules.py b/django_toosimple_q/tests/tests_schedules.py index 0c798a4..44e9ddd 100644 --- a/django_toosimple_q/tests/tests_schedules.py +++ b/django_toosimple_q/tests/tests_schedules.py @@ -121,6 +121,25 @@ def d(scheduled_on): ], ) + @freeze_time("2020-01-01", as_kwarg="frozen_datetime") + def test_manual_schedule(self, frozen_datetime): + """Testing manual schedules""" + + @schedule_task(cron="manual", datetime_kwarg="scheduled_on") + @register_task(name="normal") + def a(scheduled_on): + return f"{scheduled_on:%Y-%m-%d %H:%M}" + + self.assertEquals(len(schedules_registry), 1) + self.assertEquals(ScheduleExec.objects.count(), 0) + self.assertQueue(0) + + # a "manual" schedule never runs + management.call_command("worker", "--until_done") + frozen_datetime.move_to("2050-01-01") + management.call_command("worker", "--until_done") + self.assertQueue(0) + @freeze_time("2020-01-01", as_kwarg="frozen_datetime") def test_invalid_schedule(self, frozen_datetime): """Testing invalid schedules"""