diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35747781..d7895256 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,18 +13,20 @@ jobs: fail-fast: false matrix: # https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django django-version: ["3.2", "4.2", "5.0", "5.1"] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.10'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.10'] exclude: - django-version: "3.2" python-version: "3.11" - django-version: "3.2" python-version: "3.12" - - django-version: "5.0" - python-version: "3.8" + - django-version: "3.2" + python-version: "3.13" + - django-version: "4.2" + python-version: "3.13" - django-version: "5.0" python-version: "3.9" - - django-version: "5.1" - python-version: "3.8" + - django-version: "5.0" + python-version: "3.13" - django-version: "5.1" python-version: "3.9" @@ -55,7 +57,7 @@ jobs: DJANGO: ${{ matrix.django-version }} - name: Upload coverage reports to Codecov if: ${{ matrix.python-version != 'pypy-3.10' }} - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true # optional (default = false) token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66adc3df..a896df80 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ exclude: "migrations" repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.17.0 + rev: v3.19.0 hooks: - id: pyupgrade args: ["--py37-plus"] @@ -32,23 +32,23 @@ repos: - id: isort - repo: https://github.com/adamchainz/django-upgrade - rev: 1.21.0 + rev: 1.22.2 hooks: - id: django-upgrade args: [--target-version, "3.2"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.8.2 hooks: # Format before linting # - id: ruff-format - id: ruff - repo: https://github.com/tox-dev/pyproject-fmt - rev: 2.2.4 + rev: v2.5.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.20.2 + rev: v0.23 hooks: - id: validate-pyproject diff --git a/django_celery_beat/admin.py b/django_celery_beat/admin.py index 2c462656..89db3757 100644 --- a/django_celery_beat/admin.py +++ b/django_celery_beat/admin.py @@ -249,11 +249,18 @@ def run_tasks(self, request, queryset): return task_ids = [ - task.apply_async(args=args, kwargs=kwargs, queue=queue, - periodic_task_name=periodic_task_name) + task.apply_async( + args=args, + kwargs=kwargs, + queue=queue, + headers={'periodic_task_name': periodic_task_name} + ) if queue and len(queue) - else task.apply_async(args=args, kwargs=kwargs, - periodic_task_name=periodic_task_name) + else task.apply_async( + args=args, + kwargs=kwargs, + headers={'periodic_task_name': periodic_task_name} + ) for task, args, kwargs, queue, periodic_task_name in tasks ] tasks_run = len(task_ids) diff --git a/django_celery_beat/schedulers.py b/django_celery_beat/schedulers.py index 846b97a9..7574e81f 100644 --- a/django_celery_beat/schedulers.py +++ b/django_celery_beat/schedulers.py @@ -77,14 +77,15 @@ def __init__(self, model, app=None): if getattr(model, 'expires_', None): self.options['expires'] = getattr(model, 'expires_') - self.options['headers'] = loads(model.headers or '{}') - self.options['periodic_task_name'] = model.name + headers = loads(model.headers or '{}') + headers['periodic_task_name'] = model.name + self.options['headers'] = headers self.total_run_count = model.total_run_count self.model = model if not model.last_run_at: - model.last_run_at = self._default_now() + model.last_run_at = model.date_changed or self._default_now() # if last_run_at is not set and # model.start_time last_run_at should be in way past. # This will trigger the job to run at start_time @@ -110,7 +111,6 @@ def is_due(self): now = self._default_now() if getattr(settings, 'DJANGO_CELERY_BEAT_TZ_AWARE', True): now = maybe_make_aware(self._default_now()) - if now < self.model.start_time: # The datetime is before the start date - don't run. # send a delay to retry on start_time @@ -119,6 +119,14 @@ def is_due(self): ) return schedules.schedstate(False, delay) + # EXPIRED TASK: Disable task when expired + if self.model.expires is not None: + now = self._default_now() + if now >= self.model.expires: + self._disable(self.model) + # Don't recheck + return schedules.schedstate(False, NEVER_CHECK_TIMEOUT) + # ONE OFF TASK: Disable one off tasks after they've ran once if self.model.one_off and self.model.enabled \ and self.model.total_run_count > 0: diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py index d070bb45..439000fb 100644 --- a/t/unit/test_schedulers.py +++ b/t/unit/test_schedulers.py @@ -130,8 +130,8 @@ def test_entry(self): assert e.options['exchange'] == 'foo' assert e.options['routing_key'] == 'cpu' assert e.options['priority'] == 1 - assert e.options['headers'] == {'_schema_name': 'foobar'} - assert e.options['periodic_task_name'] == m.name + assert e.options['headers']['_schema_name'] == 'foobar' + assert e.options['headers']['periodic_task_name'] == m.name right_now = self.app.now() m2 = self.create_model_interval( @@ -213,6 +213,55 @@ def test_entry_and_model_last_run_at_with_utc_no_use_tz(self, monkeypatch): if hasattr(time, "tzset"): time.tzset() + @override_settings( + USE_TZ=False, + DJANGO_CELERY_BEAT_TZ_AWARE=False + ) + @pytest.mark.usefixtures('depends_on_current_app') + @timezone.override('Europe/Berlin') + @pytest.mark.celery(timezone='Europe/Berlin') + def test_entry_and_model_last_run_at_when_model_changed(self, monkeypatch): + old_tz = os.environ.get("TZ") + os.environ["TZ"] = "Europe/Berlin" + if hasattr(time, "tzset"): + time.tzset() + assert self.app.timezone.key == 'Europe/Berlin' + # simulate last_run_at from DB - not TZ aware but localtime + right_now = datetime.utcnow() + # make sure to use fixed date time + monkeypatch.setattr(self.Entry, '_default_now', lambda o: right_now) + m = self.create_model_crontab( + crontab(minute='*/10') + ) + m.save() + e = self.Entry(m, app=self.app) + e.save() + m.refresh_from_db() + + # The just created model has no value for date_changed + # so last_run_at should be set to the Entry._default_now() + assert m.last_run_at == e.last_run_at + + # If the model has been updated and the entry.last_run_at is None, + # entry.last_run_at should be set to the value of model.date_changed. + # see #717 + m2 = self.create_model_crontab( + crontab(minute='*/10') + ) + m2.save() + m2.refresh_from_db() + assert m2.date_changed is not None + e2 = self.Entry(m2, app=self.app) + e2.save() + assert m2.last_run_at == m2.date_changed + + if old_tz is not None: + os.environ["TZ"] = old_tz + else: + del os.environ["TZ"] + if hasattr(time, "tzset"): + time.tzset() + @override_settings( USE_TZ=False, DJANGO_CELERY_BEAT_TZ_AWARE=False, @@ -291,6 +340,35 @@ def test_one_off_task(self): assert not isdue assert delay == NEVER_CHECK_TIMEOUT + def test_task_with_expires(self): + interval = 10 + right_now = self.app.now() + one_second_later = right_now + timedelta(seconds=1) + m = self.create_model_interval(schedule(timedelta(seconds=interval)), + start_time=right_now, + expires=one_second_later) + e = self.Entry(m, app=self.app) + isdue, delay = e.is_due() + assert isdue + assert delay == interval + + m2 = self.create_model_interval(schedule(timedelta(seconds=interval)), + start_time=right_now, + expires=right_now) + e2 = self.Entry(m2, app=self.app) + isdue, delay = e2.is_due() + assert not isdue + assert delay == NEVER_CHECK_TIMEOUT + + one_second_ago = right_now - timedelta(seconds=1) + m2 = self.create_model_interval(schedule(timedelta(seconds=interval)), + start_time=right_now, + expires=one_second_ago) + e2 = self.Entry(m2, app=self.app) + isdue, delay = e2.is_due() + assert not isdue + assert delay == NEVER_CHECK_TIMEOUT + @pytest.mark.django_db class test_DatabaseSchedulerFromAppConf(SchedulerCase): @@ -869,3 +947,16 @@ def test_run_tasks(self): assert len(self.request._messages._queued_messages) == 1 queued_message = self.request._messages._queued_messages[0].message assert queued_message == '2 tasks were successfully run' + + @pytest.mark.timeout(5) + def test_run_task_headers(self, monkeypatch): + def mock_apply_async(*args, **kwargs): + self.captured_headers = kwargs.get('headers', {}) + + monkeypatch.setattr('celery.app.task.Task.apply_async', + mock_apply_async) + ma = PeriodicTaskAdmin(PeriodicTask, self.site) + self.request = self.patch_request(self.request_factory.get('/')) + ma.run_tasks(self.request, PeriodicTask.objects.filter(id=self.m1.id)) + assert 'periodic_task_name' in self.captured_headers + assert self.captured_headers['periodic_task_name'] == self.m1.name