Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-django-celery-beat for 
openSUSE:Factory checked in at 2026-01-05 16:04:13
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-django-celery-beat (Old)
 and      /work/SRC/openSUSE:Factory/.python-django-celery-beat.new.1928 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-django-celery-beat"

Mon Jan  5 16:04:13 2026 rev:6 rq:1325391 version:2.8.1

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-django-celery-beat/python-django-celery-beat.changes
      2025-04-30 19:03:10.619311322 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-django-celery-beat.new.1928/python-django-celery-beat.changes
    2026-01-05 16:04:16.039222391 +0100
@@ -1,0 +2,10 @@
+Mon Jan  5 11:44:49 UTC 2026 - Markéta Machová <[email protected]>
+
+- Update to 2.8.1
+  * Refactor / all_as_schedule crontab query optimization
+  * Consider server timezone on _get_timezone_offset instead of django's
+    settings
+  * Periodic task querying is a separate method
+- Add pytest9.patch to allow pytest 9
+
+-------------------------------------------------------------------

Old:
----
  django_celery_beat-2.8.0.tar.gz

New:
----
  django_celery_beat-2.8.1.tar.gz
  pytest9.patch

----------(New B)----------
  New:  * Periodic task querying is a separate method
- Add pytest9.patch to allow pytest 9
----------(New E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-django-celery-beat.spec ++++++
--- /var/tmp/diff_new_pack.r7OMZc/_old  2026-01-05 16:04:27.155683183 +0100
+++ /var/tmp/diff_new_pack.r7OMZc/_new  2026-01-05 16:04:27.155683183 +0100
@@ -1,7 +1,7 @@
 #
 # spec file for package python-django-celery-beat
 #
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -17,7 +17,7 @@
 
 
 Name:           python-django-celery-beat
-Version:        2.8.0
+Version:        2.8.1
 Release:        0
 Summary:        Database-backed Periodic Tasks
 License:        BSD-3-Clause
@@ -28,6 +28,8 @@
 BuildRequires:  %{python_module wheel}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
+# PATCH-FIX-UPSTREAM https://github.com/celery/django-celery-beat/pull/986 
Allow pytest 9
+Patch0:         pytest9.patch
 Requires:       python-Django >= 3.2
 Requires:       python-celery >= 5.2.3
 Requires:       python-cron-descriptor >= 1.2.32

++++++ django_celery_beat-2.8.0.tar.gz -> django_celery_beat-2.8.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/Changelog 
new/django_celery_beat-2.8.1/Changelog
--- old/django_celery_beat-2.8.0/Changelog      2025-04-16 09:15:02.000000000 
+0200
+++ new/django_celery_beat-2.8.1/Changelog      2025-05-13 08:57:51.000000000 
+0200
@@ -7,6 +7,16 @@
 Next
 ====
 
+.. _version-2.8.1:
+
+2.8.1
+=====
+:release-date: 2025-05-13
+:release-by: Asif Saif Uddin (@auvipy)
+
+- Fixed regression by big code refactoring.
+
+
 .. _version-2.8.0:
 
 2.8.0
@@ -14,9 +24,6 @@
 :release-date: 2025-04-16
 :release-by: Asif Saif Uddin (@auvipy)
 
-Added
-~~~~~
-
 - Add official support for Django 5.2.
 - Issue 796: remove days of the week from human readable description when the 
whole week is specified.
 - fix 'exipres', 'expire_seconds' not working normal as expected.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/PKG-INFO 
new/django_celery_beat-2.8.1/PKG-INFO
--- old/django_celery_beat-2.8.0/PKG-INFO       2025-04-16 09:15:04.987131600 
+0200
+++ new/django_celery_beat-2.8.1/PKG-INFO       2025-05-13 08:57:54.326757700 
+0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: django-celery-beat
-Version: 2.8.0
+Version: 2.8.1
 Summary: Database-backed Periodic Tasks.
 Home-page: https://github.com/celery/django-celery-beat
 Author: Asif Saif Uddin, Ask Solem
@@ -63,7 +63,7 @@
 
 |build-status| |coverage| |license| |wheel| |pyversion| |pyimp|
 
-:Version: 2.8.0
+:Version: 2.8.1
 :Web: http://django-celery-beat.readthedocs.io/
 :Download: http://pypi.python.org/pypi/django-celery-beat
 :Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/README.rst 
new/django_celery_beat-2.8.1/README.rst
--- old/django_celery_beat-2.8.0/README.rst     2025-04-16 09:15:02.000000000 
+0200
+++ new/django_celery_beat-2.8.1/README.rst     2025-05-13 08:57:51.000000000 
+0200
@@ -4,7 +4,7 @@
 
 |build-status| |coverage| |license| |wheel| |pyversion| |pyimp|
 
-:Version: 2.8.0
+:Version: 2.8.1
 :Web: http://django-celery-beat.readthedocs.io/
 :Download: http://pypi.python.org/pypi/django-celery-beat
 :Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_celery_beat-2.8.0/django_celery_beat/__init__.py 
new/django_celery_beat-2.8.1/django_celery_beat/__init__.py
--- old/django_celery_beat-2.8.0/django_celery_beat/__init__.py 2025-04-16 
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/__init__.py 2025-05-13 
08:57:51.000000000 +0200
@@ -5,7 +5,7 @@
 import re
 from collections import namedtuple
 
-__version__ = '2.8.0'
+__version__ = '2.8.1'
 __author__ = 'Asif Saif Uddin, Ask Solem'
 __contact__ = '[email protected], [email protected]'
 __homepage__ = 'https://github.com/celery/django-celery-beat'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_celery_beat-2.8.0/django_celery_beat/models.py 
new/django_celery_beat-2.8.1/django_celery_beat/models.py
--- old/django_celery_beat-2.8.0/django_celery_beat/models.py   2025-04-16 
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/models.py   2025-05-13 
08:57:51.000000000 +0200
@@ -65,7 +65,7 @@
     """Return timezone string from Django settings ``CELERY_TIMEZONE`` 
variable.
 
     If is not defined or is not a valid timezone, return ``"UTC"`` instead.
-    """  # noqa: E501
+    """
     try:
         CELERY_TIMEZONE = getattr(
             settings, '%s_TIMEZONE' % current_app.namespace)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_celery_beat-2.8.0/django_celery_beat/schedulers.py 
new/django_celery_beat-2.8.1/django_celery_beat/schedulers.py
--- old/django_celery_beat-2.8.0/django_celery_beat/schedulers.py       
2025-04-16 09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/schedulers.py       
2025-05-13 08:57:51.000000000 +0200
@@ -4,6 +4,11 @@
 import math
 from multiprocessing.util import Finalize
 
+try:
+    from zoneinfo import ZoneInfo  # Python 3.9+
+except ImportError:
+    from backports.zoneinfo import ZoneInfo  # Python 3.8
+
 from celery import current_app, schedules
 from celery.beat import ScheduleEntry, Scheduler
 from celery.utils.log import get_logger
@@ -11,21 +16,22 @@
 from django.conf import settings
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import close_old_connections, transaction
-from django.db.models import Q
+from django.db.models import Case, F, IntegerField, Q, When
+from django.db.models.functions import Cast
 from django.db.utils import DatabaseError, InterfaceError
-from django.utils import timezone
 from kombu.utils.encoding import safe_repr, safe_str
 from kombu.utils.json import dumps, loads
 
 from .clockedschedule import clocked
 from .models import (ClockedSchedule, CrontabSchedule, IntervalSchedule,
                      PeriodicTask, PeriodicTasks, SolarSchedule)
-from .utils import NEVER_CHECK_TIMEOUT, now
+from .utils import NEVER_CHECK_TIMEOUT, aware_now, now
 
 # This scheduler must wake up more frequently than the
 # regular of 5 minutes because it needs to take external
 # changes to the schedule into account.
 DEFAULT_MAX_INTERVAL = 5  # seconds
+SCHEDULE_SYNC_MAX_INTERVAL = 300  # 5 minutes
 
 ADD_ENTRY_ERROR = """\
 Cannot add entry %r to database schedule: %r. Contents: %r
@@ -238,6 +244,7 @@
     _last_timestamp = None
     _initial_read = True
     _heap_invalidated = False
+    _last_full_sync = None
 
     def __init__(self, *args, **kwargs):
         """Initialize the database scheduler."""
@@ -256,23 +263,144 @@
     def all_as_schedule(self):
         debug('DatabaseScheduler: Fetching database schedule')
         s = {}
-        next_five_minutes = now() + datetime.timedelta(minutes=5)
-        exclude_clock_tasks_query = Q(
-            clocked__isnull=False, clocked__clocked_time__gt=next_five_minutes
-        )
-        exclude_hours = self.get_excluded_hours_for_crontab_tasks()
-        exclude_cron_tasks_query = Q(
-            crontab__isnull=False, crontab__hour__in=exclude_hours
-        )
-        for model in self.Model.objects.enabled().exclude(
-            exclude_clock_tasks_query | exclude_cron_tasks_query
-        ):
+        for model in self.enabled_models():
             try:
                 s[model.name] = self.Entry(model, app=self.app)
             except ValueError:
                 pass
         return s
 
+    def enabled_models(self):
+        """Return list of enabled periodic tasks.
+
+        Allows overriding how the list of periodic tasks is fetched without
+        duplicating the filtering/querying logic.
+        """
+        return list(self.enabled_models_qs())
+
+    def enabled_models_qs(self):
+        next_schedule_sync = now() + datetime.timedelta(
+            seconds=SCHEDULE_SYNC_MAX_INTERVAL
+        )
+        exclude_clock_tasks_query = Q(
+            clocked__isnull=False,
+            clocked__clocked_time__gt=next_schedule_sync
+        )
+
+        exclude_cron_tasks_query = self._get_crontab_exclude_query()
+
+        # Combine the queries for optimal database filtering
+        exclude_query = exclude_clock_tasks_query | exclude_cron_tasks_query
+
+        # Fetch only the tasks we need to consider
+        return self.Model.objects.enabled().exclude(exclude_query)
+
+    def _get_crontab_exclude_query(self):
+        """
+        Build a query to exclude crontab tasks based on their hour value,
+        adjusted for timezone differences relative to the server.
+
+        This creates an annotation for each crontab task that represents the
+        server-equivalent hour, then filters on that annotation.
+        """
+        # Get server time based on Django settings
+
+        server_time = aware_now()
+        server_hour = server_time.hour
+
+        # Window of +/- 2 hours around the current hour in server tz.
+        hours_to_include = [
+            (server_hour + offset) % 24 for offset in range(-2, 3)
+        ]
+        hours_to_include += [4]  # celery's default cleanup task
+
+        # Regex pattern to match only numbers
+        # This ensures we only process numeric hour values
+        numeric_hour_pattern = r'^\d+$'
+
+        # Get all tasks with a simple numeric hour value
+        numeric_hour_tasks = CrontabSchedule.objects.filter(
+            hour__regex=numeric_hour_pattern
+        )
+
+        # Annotate these tasks with their server-hour equivalent
+        annotated_tasks = numeric_hour_tasks.annotate(
+            # Cast hour string to integer
+            hour_int=Cast('hour', IntegerField()),
+
+            # Calculate server-hour based on timezone offset
+            server_hour=Case(
+                # Handle each timezone specifically
+                *[
+                    When(
+                        timezone=timezone_name,
+                        then=(
+                            F('hour_int')
+                            + self._get_timezone_offset(timezone_name)
+                            + 24
+                        ) % 24
+                    )
+                    for timezone_name in self._get_unique_timezone_names()
+                ],
+                # Default case - use hour as is
+                default=F('hour_int')
+            )
+        )
+
+        excluded_hour_task_ids = annotated_tasks.exclude(
+            server_hour__in=hours_to_include
+        ).values_list('id', flat=True)
+
+        # Build the final exclude query:
+        # Exclude crontab tasks that are not in our include list
+        exclude_query = Q(crontab__isnull=False) & Q(
+            crontab__id__in=excluded_hour_task_ids
+        )
+
+        return exclude_query
+
+    def _get_unique_timezone_names(self):
+        """Get a list of all unique timezone names used in CrontabSchedule"""
+        return CrontabSchedule.objects.values_list(
+            'timezone', flat=True
+        ).distinct()
+
+    def _get_timezone_offset(self, timezone_name):
+        """
+        Args:
+            timezone_name: The name of the timezone or a ZoneInfo object
+
+        Returns:
+            int: The hour offset
+        """
+        # Get server timezone
+        server_time = aware_now()
+        # Use server_time.tzinfo directly if it is already a ZoneInfo instance
+        if isinstance(server_time.tzinfo, ZoneInfo):
+            server_tz = server_time.tzinfo
+        else:
+            server_tz = ZoneInfo(str(server_time.tzinfo))
+
+        if isinstance(timezone_name, ZoneInfo):
+            timezone_name = timezone_name.key
+
+        target_tz = ZoneInfo(timezone_name)
+
+        # Use a fixed point in time for the calculation to avoid DST issues
+        fixed_dt = datetime.datetime(2023, 1, 1, 12, 0, 0)
+
+        # Calculate the offset
+        dt1 = fixed_dt.replace(tzinfo=server_tz)
+        dt2 = fixed_dt.replace(tzinfo=target_tz)
+
+        # Calculate hour difference
+        offset_seconds = (
+            dt1.utcoffset().total_seconds() - dt2.utcoffset().total_seconds()
+        )
+        offset_hours = int(offset_seconds / 3600)
+
+        return offset_hours
+
     def schedule_changed(self):
         try:
             close_old_connections()
@@ -372,13 +500,31 @@
     @property
     def schedule(self):
         initial = update = False
+        current_time = datetime.datetime.now()
+
         if self._initial_read:
             debug('DatabaseScheduler: initial read')
             initial = update = True
             self._initial_read = False
+            self._last_full_sync = current_time
         elif self.schedule_changed():
             info('DatabaseScheduler: Schedule changed.')
             update = True
+            self._last_full_sync = current_time
+
+        # Force update the schedule if it's been more than 5 minutes
+        if not update:
+            time_since_last_sync = (
+                current_time - self._last_full_sync
+            ).total_seconds()
+            if (
+                time_since_last_sync >= SCHEDULE_SYNC_MAX_INTERVAL
+            ):
+                debug(
+                    'DatabaseScheduler: Forcing full sync after 5 minutes'
+                )
+                update = True
+                self._last_full_sync = current_time
 
         if update:
             self.sync()
@@ -392,32 +538,3 @@
                     repr(entry) for entry in self._schedule.values()),
                 )
         return self._schedule
-
-    @staticmethod
-    def get_excluded_hours_for_crontab_tasks():
-        # Generate the full list of allowed hours for crontabs
-        allowed_crontab_hours = [
-            f"{hour:02}" for hour in range(24)
-        ] + [
-            str(hour) for hour in range(10)
-        ]
-
-        # Get current, next, and previous hours
-        current_time = timezone.localtime(now())
-        current_hour = current_time.hour
-        next_hour = (current_hour + 1) % 24
-        previous_hour = (current_hour - 1) % 24
-
-        # Create a set of hours to remove (both padded and non-padded versions)
-        hours_to_remove = {
-            f"{current_hour:02}", str(current_hour),
-            f"{next_hour:02}", str(next_hour),
-            f"{previous_hour:02}", str(previous_hour),
-            str(4), "04",  # celery's default cleanup task
-        }
-
-        # Filter out 'should be considered' hours
-        return [
-            hour for hour in allowed_crontab_hours
-            if hour not in hours_to_remove
-        ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/django_celery_beat/utils.py 
new/django_celery_beat-2.8.1/django_celery_beat/utils.py
--- old/django_celery_beat-2.8.0/django_celery_beat/utils.py    2025-04-16 
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat/utils.py    2025-05-13 
08:57:51.000000000 +0200
@@ -1,8 +1,14 @@
 """Utilities."""
+import datetime
 # -- XXX This module must not use translation as that causes
 # -- a recursive loader import!
 from datetime import timezone as datetime_timezone
 
+try:
+    from zoneinfo import ZoneInfo  # Python 3.9+
+except ImportError:
+    from backports.zoneinfo import ZoneInfo  # Python 3.8
+
 from django.conf import settings
 from django.utils import timezone
 
@@ -37,6 +43,16 @@
         return timezone.now()
 
 
+def aware_now():
+    if getattr(settings, 'USE_TZ', True):
+        # When USE_TZ is True, return timezone.now()
+        return timezone.now()
+    else:
+        # When USE_TZ is False, use the project's timezone
+        project_tz = ZoneInfo(getattr(settings, 'TIME_ZONE', 'UTC'))
+        return datetime.datetime.now(project_tz)
+
+
 def is_database_scheduler(scheduler):
     """Return true if Celery is configured to use the db scheduler."""
     if not scheduler:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_celery_beat-2.8.0/django_celery_beat.egg-info/PKG-INFO 
new/django_celery_beat-2.8.1/django_celery_beat.egg-info/PKG-INFO
--- old/django_celery_beat-2.8.0/django_celery_beat.egg-info/PKG-INFO   
2025-04-16 09:15:04.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat.egg-info/PKG-INFO   
2025-05-13 08:57:54.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: django-celery-beat
-Version: 2.8.0
+Version: 2.8.1
 Summary: Database-backed Periodic Tasks.
 Home-page: https://github.com/celery/django-celery-beat
 Author: Asif Saif Uddin, Ask Solem
@@ -63,7 +63,7 @@
 
 |build-status| |coverage| |license| |wheel| |pyversion| |pyimp|
 
-:Version: 2.8.0
+:Version: 2.8.1
 :Web: http://django-celery-beat.readthedocs.io/
 :Download: http://pypi.python.org/pypi/django-celery-beat
 :Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_celery_beat-2.8.0/django_celery_beat.egg-info/SOURCES.txt 
new/django_celery_beat-2.8.1/django_celery_beat.egg-info/SOURCES.txt
--- old/django_celery_beat-2.8.0/django_celery_beat.egg-info/SOURCES.txt        
2025-04-16 09:15:04.000000000 +0200
+++ new/django_celery_beat-2.8.1/django_celery_beat.egg-info/SOURCES.txt        
2025-05-13 08:57:54.000000000 +0200
@@ -109,4 +109,5 @@
 t/unit/test_admin.py
 t/unit/test_crontabs.py
 t/unit/test_models.py
-t/unit/test_schedulers.py
\ No newline at end of file
+t/unit/test_schedulers.py
+t/unit/test_utils.py
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/docs/copyright.rst 
new/django_celery_beat-2.8.1/docs/copyright.rst
--- old/django_celery_beat-2.8.0/docs/copyright.rst     2025-04-16 
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/docs/copyright.rst     2025-05-13 
08:57:51.000000000 +0200
@@ -3,11 +3,11 @@
 
 *django-celery-beat User Manual*
 
-by Ask Solem
+by Ask Solem & Asif Saif Uddin
 
 .. |copy|   unicode:: U+000A9 .. COPYRIGHT SIGN
 
-Copyright |copy| 2016, Ask Solem
+Copyright |copy| 2016, Ask Solem, 2017-2036, Asif Saif Uddin,
 
 All rights reserved.  This material may be copied or distributed only
 subject to the terms and conditions set forth in the `Creative Commons
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_celery_beat-2.8.0/docs/includes/introduction.txt 
new/django_celery_beat-2.8.1/docs/includes/introduction.txt
--- old/django_celery_beat-2.8.0/docs/includes/introduction.txt 2025-04-16 
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/docs/includes/introduction.txt 2025-05-13 
08:57:51.000000000 +0200
@@ -1,4 +1,4 @@
-:Version: 2.8.0
+:Version: 2.8.1
 :Web: http://django-celery-beat.readthedocs.io/
 :Download: http://pypi.python.org/pypi/django-celery-beat
 :Source: http://github.com/celery/django-celery-beat
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/setup.cfg 
new/django_celery_beat-2.8.1/setup.cfg
--- old/django_celery_beat-2.8.0/setup.cfg      2025-04-16 09:15:04.987131600 
+0200
+++ new/django_celery_beat-2.8.1/setup.cfg      2025-05-13 08:57:54.326757700 
+0200
@@ -7,6 +7,7 @@
 ignore = N806, N802, N801, N803, W503, W504
 exclude = 
        **/migrations/*.py
+max-line-length = 88
 
 [pep257]
 ignore = D102,D104,D203,D105,D213
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/t/unit/test_schedulers.py 
new/django_celery_beat-2.8.1/t/unit/test_schedulers.py
--- old/django_celery_beat-2.8.0/t/unit/test_schedulers.py      2025-04-16 
09:15:02.000000000 +0200
+++ new/django_celery_beat-2.8.1/t/unit/test_schedulers.py      2025-05-13 
08:57:51.000000000 +0200
@@ -4,6 +4,7 @@
 from datetime import datetime, timedelta
 from itertools import count
 from time import monotonic
+from unittest.mock import patch
 
 try:
     from zoneinfo import ZoneInfo  # Python 3.9+
@@ -306,40 +307,50 @@
         interval = 10
         right_now = self.app.now()
         one_interval_ago = right_now - timedelta(seconds=interval)
-        m = self.create_model_interval(schedule(timedelta(seconds=interval)),
-                                       start_time=right_now,
-                                       last_run_at=one_interval_ago)
+        m = self.create_model_interval(
+            schedule(timedelta(seconds=interval)),
+            start_time=right_now,
+            last_run_at=one_interval_ago
+        )
         e = self.Entry(m, app=self.app)
         isdue, delay = e.is_due()
         assert isdue
         assert delay == interval
 
         tomorrow = right_now + timedelta(days=1)
-        m2 = self.create_model_interval(schedule(timedelta(seconds=interval)),
-                                        start_time=tomorrow,
-                                        last_run_at=one_interval_ago)
+        m2 = self.create_model_interval(
+            schedule(timedelta(seconds=interval)),
+            start_time=tomorrow,
+            last_run_at=one_interval_ago
+        )
         e2 = self.Entry(m2, app=self.app)
         isdue, delay = e2.is_due()
         assert not isdue
-        assert delay == math.ceil((tomorrow - right_now).total_seconds())
+        assert delay == math.ceil(
+            (tomorrow - right_now).total_seconds()
+        )
 
     def test_one_off_task(self):
         interval = 10
         right_now = self.app.now()
         one_interval_ago = right_now - timedelta(seconds=interval)
-        m = self.create_model_interval(schedule(timedelta(seconds=interval)),
-                                       one_off=True,
-                                       last_run_at=one_interval_ago,
-                                       total_run_count=0)
+        m = self.create_model_interval(
+            schedule(timedelta(seconds=interval)),
+            one_off=True,
+            last_run_at=one_interval_ago,
+            total_run_count=0
+        )
         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)),
-                                        one_off=True,
-                                        last_run_at=one_interval_ago,
-                                        total_run_count=1)
+        m2 = self.create_model_interval(
+            schedule(timedelta(seconds=interval)),
+            one_off=True,
+            last_run_at=one_interval_ago,
+            total_run_count=1
+        )
         e2 = self.Entry(m2, app=self.app)
         isdue, delay = e2.is_due()
         assert not isdue
@@ -349,26 +360,32 @@
         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)
+        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)
+        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)
+        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
@@ -519,7 +536,7 @@
 
         # distant future time (should not be in schedule)
         self.m11 = self.create_model_crontab(
-            crontab(hour=str((now_hour + 2) % 24)))
+            crontab(hour=str((now_hour + 3) % 24)))
         self.m11.save()
         self.m11.refresh_from_db()
 
@@ -533,19 +550,46 @@
     def test_all_as_schedule(self):
         sched = self.s.schedule
         assert sched
-        assert len(sched) == 9
-        assert 'celery.backend_cleanup' in sched
-        for n, e in sched.items():
-            assert isinstance(e, self.s.Entry)
 
-    def test_get_excluded_hours_for_crontab_tasks(self):
-        now_hour = timezone.localtime(timezone.now()).hour
-        excluded_hours = self.s.get_excluded_hours_for_crontab_tasks()
+        # Check for presence of standard tasks
+        expected_task_names = {
+            self.m1.name,  # interval task
+            self.m2.name,  # interval task
+            self.m3.name,  # crontab task
+            self.m4.name,  # solar task
+            self.m6.name,  # clocked task (near future)
+            self.m8.name,  # crontab task (current hour)
+            self.m9.name,  # crontab task (current hour + 1)
+            self.m10.name,  # crontab task (current hour - 1)
+            'celery.backend_cleanup'  # auto-added by system
+        }
+
+        # The distant future crontab task (hour + 3) should be excluded
+        distant_task_name = self.m11.name
+
+        # But if it's hour is 4 (or converts to 4 after timezone adjustment),
+        # it would be included because of the special handling for hour=4
+        current_hour = timezone.localtime(timezone.now()).hour
+        is_hour_four_task = False
+
+        # Check if the task would have hour 4.
+        if self.m11.crontab.hour == '4' or (current_hour + 3) % 24 == 4:
+            is_hour_four_task = True
+
+        # Add to expected tasks if it's an hour=4 task
+        if is_hour_four_task:
+            expected_task_names.add(distant_task_name)
+
+        # Verify all expected tasks are present in the schedule
+        schedule_task_names = set(sched.keys())
+        assert schedule_task_names == expected_task_names, (
+            f"Task mismatch. Expected: {expected_task_names}, "
+            f"Got: {schedule_task_names}"
+        )
 
-        assert str(now_hour) not in excluded_hours
-        assert str((now_hour + 1) % 24) not in excluded_hours
-        assert str((now_hour - 1) % 24) not in excluded_hours
-        assert str(4) not in excluded_hours
+        # Verify all entries are the right type
+        for n, e in sched.items():
+            assert isinstance(e, self.s.Entry)
 
     def test_schedule_changed(self):
         self.m2.args = '[16, 16]'
@@ -722,7 +766,9 @@
         assert self.s.schedules_equal(self.s.schedule, self.s.schedule)
 
         monkeypatch.setattr(self.s, 'schedule_changed', lambda: True)
-        assert not self.s.schedules_equal(self.s.schedule, self.s.schedule)
+        assert not self.s.schedules_equal(
+            self.s.schedule, self.s.schedule
+        )
 
     def test_heap_always_return_the_first_item(self):
         interval = 10
@@ -898,6 +944,274 @@
             assert s._heap[0][2].name != m2.name
             is_due, _ = e2.is_due()
 
+    @pytest.mark.django_db
+    def test_crontab_exclusion_logic_basic(self):
+        current_hour = datetime.now().hour
+        cron_current_hour = CrontabSchedule.objects.create(
+            hour=str(current_hour)
+        )
+        cron_plus_one = CrontabSchedule.objects.create(
+            hour=str((current_hour + 1) % 24)
+        )
+        cron_plus_two = CrontabSchedule.objects.create(
+            hour=str((current_hour + 2) % 24)
+        )
+        cron_minus_one = CrontabSchedule.objects.create(
+            hour=str((current_hour - 1) % 24)
+        )
+        cron_minus_two = CrontabSchedule.objects.create(
+            hour=str((current_hour - 2) % 24)
+        )
+        cron_outside = CrontabSchedule.objects.create(
+            hour=str((current_hour + 5) % 24)
+        )
+
+        # Create a special hour 4 schedule that should always be included
+        cron_hour_four = CrontabSchedule.objects.create(hour='4')
+
+        # Create periodic tasks using these schedules
+        task_current = self.create_model(
+            name='task-current',
+            crontab=cron_current_hour
+        )
+        task_current.save()
+
+        task_plus_one = self.create_model(
+            name='task-plus-one',
+            crontab=cron_plus_one
+        )
+        task_plus_one.save()
+
+        task_plus_two = self.create_model(
+            name='task-plus-two',
+            crontab=cron_plus_two
+        )
+        task_plus_two.save()
+
+        task_minus_one = self.create_model(
+            name='task-minus-one',
+            crontab=cron_minus_one
+        )
+        task_minus_one.save()
+
+        task_minus_two = self.create_model(
+            name='task-minus-two',
+            crontab=cron_minus_two
+        )
+        task_minus_two.save()
+
+        task_outside = self.create_model(
+            name='task-outside',
+            crontab=cron_outside
+        )
+        task_outside.save()
+
+        task_hour_four = self.create_model(
+            name='task-hour-four',
+            crontab=cron_hour_four
+        )
+        task_hour_four.save()
+
+        # Run the scheduler's exclusion logic
+        exclude_query = self.s._get_crontab_exclude_query()
+
+        # Get excluded task IDs
+        excluded_tasks = set(
+            PeriodicTask.objects.filter(exclude_query).values_list(
+                'id', flat=True
+            )
+        )
+
+        # The test that matters is that hour 4 is always included
+        assert task_hour_four.id not in excluded_tasks
+
+        # Assert that tasks within the time window are not excluded
+        for task_id in [task_current.id, task_plus_one.id, task_plus_two.id,
+                        task_minus_one.id, task_minus_two.id]:
+            assert task_id not in excluded_tasks
+
+        if task_outside.crontab.hour != '4':
+            assert task_outside.id in excluded_tasks
+
+    @pytest.mark.django_db
+    def test_crontab_special_hour_four(self):
+        """
+        Test that schedules with hour=4 are always included, regardless of
+        the current time.
+        """
+        # Create a task with hour=4
+        cron_hour_four = CrontabSchedule.objects.create(hour='4')
+        task_hour_four = self.create_model(
+            name='special-cleanup-task',
+            crontab=cron_hour_four
+        )
+
+        # Run the scheduler's exclusion logic
+        exclude_query = self.s._get_crontab_exclude_query()
+
+        # Get excluded task IDs
+        excluded_tasks = set(
+            PeriodicTask.objects.filter(exclude_query).values_list(
+                'id', flat=True
+            )
+        )
+
+        # The hour=4 task should never be excluded
+        assert task_hour_four.id not in excluded_tasks
+
+    @pytest.mark.django_db
+    @patch('django_celery_beat.schedulers.aware_now')
+    @patch('django.utils.timezone.get_current_timezone')
+    def test_crontab_timezone_conversion(self, mock_get_tz, mock_aware_now):
+        # Set up mocks for server timezone and current time
+        from datetime import datetime
+        server_tz = ZoneInfo("Asia/Tokyo")
+
+        mock_get_tz.return_value = server_tz
+
+        # Server time is 17:00 Tokyo time
+        mock_now_dt = datetime(2023, 1, 1, 17, 0, 0, tzinfo=server_tz)
+        mock_aware_now.return_value = mock_now_dt
+
+        # Create tasks with the following crontab schedules:
+        # 1. UTC task at hour 8 - equivalent to 17:00 Tokyo time (current hour)
+        #    - should be included
+        # 2. New York task at hour 3 - equivalent to 17:00 Tokyo time
+        #    (current hour) - should be included
+        # 3. UTC task at hour 3 - equivalent to 12:00 Tokyo time
+        #    - should be excluded (outside window)
+
+        # Create crontab schedules in different timezones
+        utc_current_hour = CrontabSchedule.objects.create(
+            hour='8', timezone='UTC'
+        )
+        ny_current_hour = CrontabSchedule.objects.create(
+            hour='3', timezone='America/New_York'
+        )
+        utc_outside_window = CrontabSchedule.objects.create(
+            hour='3', timezone='UTC'
+        )
+
+        # Create periodic tasks using these schedules
+        task_utc_current = self.create_model(
+            name='utc-current-hour',
+            crontab=utc_current_hour
+        )
+        task_utc_current.save()
+
+        task_ny_current = self.create_model(
+            name='ny-current-hour',
+            crontab=ny_current_hour
+        )
+        task_ny_current.save()
+
+        task_utc_outside = self.create_model(
+            name='utc-outside-window',
+            crontab=utc_outside_window
+        )
+        task_utc_outside.save()
+
+        # Run the scheduler's exclusion logic
+        exclude_query = self.s._get_crontab_exclude_query()
+
+        # Get excluded task IDs
+        excluded_tasks = set(
+            PeriodicTask.objects.filter(exclude_query).values_list(
+                'id', flat=True
+            )
+        )
+
+        # Current hour tasks in different timezones should not be excluded
+        assert task_utc_current.id not in excluded_tasks, (
+            "UTC current hour task should be included"
+        )
+        assert task_ny_current.id not in excluded_tasks, (
+            "New York current hour task should be included"
+        )
+
+        # Task outside window should be excluded
+        assert task_utc_outside.id in excluded_tasks, (
+            "UTC outside window task should be excluded"
+        )
+
+    @pytest.mark.django_db
+    @patch('django.utils.timezone.get_current_timezone')
+    @patch('django_celery_beat.schedulers.aware_now')
+    def test_crontab_timezone_conversion_with_negative_offset_and_dst(
+        self, mock_aware_now, mock_get_tz
+    ):
+        # Set up mocks for server timezone and current time
+        from datetime import datetime
+
+        server_tz = ZoneInfo("UTC")
+
+        mock_get_tz.return_value = server_tz
+
+        # Server time is 17:00 UTC in June
+        mock_now_dt = datetime(2023, 6, 1, 17, 0, 0, tzinfo=server_tz)
+        mock_aware_now.return_value = mock_now_dt
+
+        # Create tasks with the following crontab schedules:
+        # 1. Asia/Tokyo task at hour 2 - equivalent to 17:00 UTC (current hour)
+        #    - should be included
+        # 2. Europe/Paris task at hour 19 - equivalent to 17:00 UTC
+        #    (current hour) - should be included
+        # 3. Europe/Paris task at hour 15 - equivalent to 13:00 UTC
+        #    - should be excluded (outside window)
+
+        # Create crontab schedules in different timezones
+        tokyo_current_hour = CrontabSchedule.objects.create(
+            hour='2', timezone='Asia/Tokyo'
+        )
+        paris_current_hour = CrontabSchedule.objects.create(
+            hour='19', timezone='Europe/Paris'
+        )
+        paris_outside_window = CrontabSchedule.objects.create(
+            hour='14', timezone='Europe/Paris'
+        )
+
+        # Create periodic tasks using these schedules
+        task_utc_current = self.create_model(
+            name='tokyo-current-hour',
+            crontab=tokyo_current_hour
+        )
+        task_utc_current.save()
+
+        task_ny_current = self.create_model(
+            name='paros-current-hour',
+            crontab=paris_current_hour
+        )
+        task_ny_current.save()
+
+        task_utc_outside = self.create_model(
+            name='paris-outside-window',
+            crontab=paris_outside_window
+        )
+        task_utc_outside.save()
+
+        # Run the scheduler's exclusion logic
+        exclude_query = self.s._get_crontab_exclude_query()
+
+        # Get excluded task IDs
+        excluded_tasks = set(
+            PeriodicTask.objects.filter(exclude_query).values_list(
+                'id', flat=True
+            )
+        )
+
+        # Current hour tasks in different timezones should not be excluded
+        assert task_utc_current.id not in excluded_tasks, (
+            "Tokyo current hour task should be included"
+        )
+        assert task_ny_current.id not in excluded_tasks, (
+            "Paris current hour task should be included"
+        )
+
+        # Task outside window should be excluded
+        assert task_utc_outside.id in excluded_tasks, (
+            "Paris outside window task should be excluded"
+        )
+
 
 @pytest.mark.django_db
 class test_models(SchedulerCase):
@@ -1018,7 +1332,7 @@
         isdue, nextcheck = s.schedule.is_due(dt_lastrun)
         assert isdue is False  # False means task isn't due, but keep checking.
         assert (nextcheck > 0) and (isdue is False) or \
-            (nextcheck == s.max_interval) and (isdue is True)
+               (nextcheck == s.max_interval) and (isdue is True)
 
         s2 = SolarSchedule(event='solar_noon', latitude=48.06, longitude=12.86)
         dt2 = datetime(day=26, month=7, year=2000, hour=1, minute=0)
@@ -1028,23 +1342,26 @@
         isdue2, nextcheck2 = s2.schedule.is_due(dt2_lastrun)
         assert isdue2 is True  # True means task is due and should run.
         assert (nextcheck2 > 0) and (isdue2 is True) or \
-            (nextcheck2 == s2.max_interval) and (isdue2 is False)
+               (nextcheck2 == s2.max_interval) and (isdue2 is False)
 
     def test_ClockedSchedule_schedule(self):
-        due_datetime = make_aware(datetime(day=26,
-                                           month=7,
-                                           year=3000,
-                                           hour=1,
-                                           minute=0))  # future time
+        due_datetime = make_aware(datetime(
+            day=26,
+            month=7,
+            year=3000,
+            hour=1,
+            minute=0
+        ))  # future time
         s = ClockedSchedule(clocked_time=due_datetime)
         dt = datetime(day=25, month=7, year=2050, hour=1, minute=0)
         dt_lastrun = make_aware(dt)
 
         assert s.schedule is not None
         isdue, nextcheck = s.schedule.is_due(dt_lastrun)
-        assert isdue is False  # False means task isn't due, but keep checking.
+        # False means task isn't due, but keep checking.
+        assert isdue is False
         assert (nextcheck > 0) and (isdue is False) or \
-            (nextcheck == s.max_interval) and (isdue is True)
+               (nextcheck == s.max_interval) and (isdue is True)
 
         due_datetime = make_aware(datetime.now())
         s = ClockedSchedule(clocked_time=due_datetime)
@@ -1136,3 +1453,54 @@
         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
+
+
[email protected]_db
+class test_timezone_offset_handling:
+    def setup_method(self):
+        self.app = patch("django_celery_beat.schedulers.current_app").start()
+
+    def teardown_method(self):
+        patch.stopall()
+
+    @patch("django_celery_beat.schedulers.aware_now")
+    def test_server_timezone_handling_with_zoneinfo(self, mock_aware_now):
+        """Test handling when server timezone is already a ZoneInfo 
instance."""
+
+        # Create a mock scheduler with only the methods we need to test
+        class MockScheduler:
+            _get_timezone_offset = 
schedulers.DatabaseScheduler._get_timezone_offset
+
+        s = MockScheduler()
+
+        tokyo_tz = ZoneInfo("Asia/Tokyo")
+        mock_now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=tokyo_tz)
+        mock_aware_now.return_value = mock_now
+
+        # Test with a different timezone
+        new_york_tz = "America/New_York"
+        offset = s._get_timezone_offset(new_york_tz)  # Pass self explicitly
+
+        # Tokyo is UTC+9, New York is UTC-5, so difference should be 14 hours
+        assert offset == 14
+        assert mock_aware_now.called
+
+    @patch("django_celery_beat.schedulers.aware_now")
+    def test_timezone_offset_with_zoneinfo_object_param(self, mock_aware_now):
+        """Test handling when timezone_name parameter is a ZoneInfo object."""
+
+        class MockScheduler:
+            _get_timezone_offset = 
schedulers.DatabaseScheduler._get_timezone_offset
+
+        s = MockScheduler()
+
+        tokyo_tz = ZoneInfo("Asia/Tokyo")
+        mock_now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=tokyo_tz)
+        mock_aware_now.return_value = mock_now
+
+        # Test with a ZoneInfo object as parameter
+        new_york_tz = ZoneInfo("America/New_York")
+        offset = s._get_timezone_offset(new_york_tz)  # Pass self explicitly
+
+        # Tokyo is UTC+9, New York is UTC-5, so difference should be 14 hours
+        assert offset == 14
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_celery_beat-2.8.0/t/unit/test_utils.py 
new/django_celery_beat-2.8.1/t/unit/test_utils.py
--- old/django_celery_beat-2.8.0/t/unit/test_utils.py   1970-01-01 
01:00:00.000000000 +0100
+++ new/django_celery_beat-2.8.1/t/unit/test_utils.py   2025-05-13 
08:57:51.000000000 +0200
@@ -0,0 +1,30 @@
+import pytest
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from django_celery_beat.utils import aware_now
+
+
[email protected]_db
+class TestUtils(TestCase):
+    def test_aware_now_with_use_tz_true(self):
+        """Test aware_now when USE_TZ is True"""
+        with override_settings(USE_TZ=True):
+            result = aware_now()
+            assert timezone.is_aware(result)
+            # Convert both timezones to string for comparison
+            assert str(result.tzinfo) == str(timezone.get_current_timezone())
+
+    def test_aware_now_with_use_tz_false(self):
+        """Test aware_now when USE_TZ is False"""
+        with override_settings(USE_TZ=False, TIME_ZONE="Asia/Tokyo"):
+            result = aware_now()
+            assert timezone.is_aware(result)
+            assert result.tzinfo.key == "Asia/Tokyo"
+
+    def test_aware_now_with_use_tz_false_default_timezone(self):
+        """Test aware_now when USE_TZ is False and default TIME_ZONE"""
+        with override_settings(USE_TZ=False):  # Let Django use its default UTC
+            result = aware_now()
+            assert timezone.is_aware(result)
+            assert str(result.tzinfo) == "UTC"

++++++ pytest9.patch ++++++
>From 78d62f41108a2c4a52f41f300ab91ea34962810e Mon Sep 17 00:00:00 2001
From: Colin Watson <[email protected]>
Date: Thu, 1 Jan 2026 16:30:50 +0000
Subject: [PATCH] Allow pytest 9

https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function
notes that applying a mark to a fixture function never had any effect.
It raises an exception during test collection with pytest 9.
---
 requirements/test.txt     | 2 +-
 t/unit/test_schedulers.py | 3 ---
 2 files changed, 1 insertion(+), 4 deletions(-)

diff --git a/requirements/test.txt b/requirements/test.txt
index bd798673..8c3fd3f0 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -4,7 +4,7 @@ pytest-timeout
 
 # Conditional dependencies
 pytest>=6.2.5,<8.0; python_version < '3.9'  # Python 3.8 only
-pytest>=6.2.5,<9.0; python_version >= '3.9'  # Python 3.9+ only
+pytest>=6.2.5,<10.0; python_version >= '3.9'  # Python 3.9+ only
 pytest-django>=4.5.2,<4.6.0; python_version < '3.9'  # Python 3.8 only
 pytest-django>=4.5.2,<5.0; python_version >= '3.9'    # Python 3.9+ only
 backports.zoneinfo; python_version < '3.9'  # Python 3.8 only
diff --git a/t/unit/test_schedulers.py b/t/unit/test_schedulers.py
index 1640f6b4..85c6694f 100644
--- a/t/unit/test_schedulers.py
+++ b/t/unit/test_schedulers.py
@@ -561,7 +561,6 @@ def test_task_with_expires(self):
 class test_DatabaseSchedulerFromAppConf(SchedulerCase):
     Scheduler = TrackingScheduler
 
-    @pytest.mark.django_db
     @pytest.fixture(autouse=True)
     def setup_scheduler(self, app):
         self.app = app
@@ -617,7 +616,6 @@ def test_periodic_task_model_schedule_type_change(self):
 class test_DatabaseScheduler(SchedulerCase):
     Scheduler = TrackingScheduler
 
-    @pytest.mark.django_db
     @pytest.fixture(autouse=True)
     def setup_scheduler(self, app):
         self.app = app
@@ -1588,7 +1586,6 @@ def test_track_changes(self):
 
 @pytest.mark.django_db
 class test_modeladmin_PeriodicTaskAdmin(SchedulerCase):
-    @pytest.mark.django_db
     @pytest.fixture(autouse=True)
     def setup_scheduler(self, app):
         self.app = app

Reply via email to