From d12d6e7cdb61ab4fbeb70fe041f041712de1127a Mon Sep 17 00:00:00 2001 From: Joel Falknau Date: Sun, 29 Dec 2024 22:04:52 +1000 Subject: [PATCH] move to its own module --- allianceauth/apps.py | 2 +- allianceauth/crontab/__init__.py | 3 + allianceauth/crontab/apps.py | 14 ++++ allianceauth/{framework => crontab}/cron.py | 3 +- allianceauth/{framework => crontab}/models.py | 2 +- allianceauth/crontab/tests/__init__.py | 0 allianceauth/crontab/tests/test_cron.py | 79 +++++++++++++++++++ allianceauth/crontab/tests/test_models.py | 66 ++++++++++++++++ .../project_name/settings/base.py | 1 + 9 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 allianceauth/crontab/__init__.py create mode 100644 allianceauth/crontab/apps.py rename allianceauth/{framework => crontab}/cron.py (96%) rename allianceauth/{framework => crontab}/models.py (91%) create mode 100644 allianceauth/crontab/tests/__init__.py create mode 100644 allianceauth/crontab/tests/test_cron.py create mode 100644 allianceauth/crontab/tests/test_models.py diff --git a/allianceauth/apps.py b/allianceauth/apps.py index 3a831f59..c2397c9b 100644 --- a/allianceauth/apps.py +++ b/allianceauth/apps.py @@ -8,7 +8,7 @@ class AllianceAuthConfig(AppConfig): def ready(self) -> None: import allianceauth.checks # noqa from django_celery_beat.models import CrontabSchedule, PeriodicTask - from allianceauth.framework.cron import offset_cron + from allianceauth.crontab.cron import offset_cron PeriodicTask.objects.update_or_create( name='esi_cleanup_callbackredirect', diff --git a/allianceauth/crontab/__init__.py b/allianceauth/crontab/__init__.py new file mode 100644 index 00000000..c621f6d2 --- /dev/null +++ b/allianceauth/crontab/__init__.py @@ -0,0 +1,3 @@ +""" +Alliance Auth Crontab Utilities +""" diff --git a/allianceauth/crontab/apps.py b/allianceauth/crontab/apps.py new file mode 100644 index 00000000..e47e39ed --- /dev/null +++ b/allianceauth/crontab/apps.py @@ -0,0 +1,14 @@ +""" +Crontab App Config +""" + +from django.apps import AppConfig + + +class CrontabConfig(AppConfig): + """ + Crontab App Config + """ + + name = "allianceauth.crontab" + label = "crontab" diff --git a/allianceauth/framework/cron.py b/allianceauth/crontab/cron.py similarity index 96% rename from allianceauth/framework/cron.py rename to allianceauth/crontab/cron.py index c8f54cbd..bbeecddd 100644 --- a/allianceauth/framework/cron.py +++ b/allianceauth/crontab/cron.py @@ -1,10 +1,11 @@ from celery.schedules import crontab import logging -from allianceauth.framework.models import CronOffset +from allianceauth.crontab.models import CronOffset from django.db import ProgrammingError logger = logging.getLogger(__name__) + def offset_cron(schedule: crontab) -> crontab: """Take a crontab and apply a series of precalculated offsets to spread out tasks execution on remote resources diff --git a/allianceauth/framework/models.py b/allianceauth/crontab/models.py similarity index 91% rename from allianceauth/framework/models.py rename to allianceauth/crontab/models.py index 4210c63d..f23b35ea 100644 --- a/allianceauth/framework/models.py +++ b/allianceauth/crontab/models.py @@ -13,7 +13,7 @@ class CronOffset(SingletonModel): minute = models.FloatField(_("Minute Offset"), default=random_default) hour = models.FloatField(_("Hour Offset"), default=random_default) day_of_month = models.FloatField(_("Day of Month Offset"), default=random_default) - month_of_year = models.FloatField(_("Month Of Year Offset"), default=random_default) + month_of_year = models.FloatField(_("Month of Year Offset"), default=random_default) day_of_week = models.FloatField(_("Day of Week Offset"), default=random_default) def __str__(self) -> str: diff --git a/allianceauth/crontab/tests/__init__.py b/allianceauth/crontab/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/crontab/tests/test_cron.py b/allianceauth/crontab/tests/test_cron.py new file mode 100644 index 00000000..c9a2a970 --- /dev/null +++ b/allianceauth/crontab/tests/test_cron.py @@ -0,0 +1,79 @@ +# myapp/tests/test_tasks.py + +import logging +from unittest.mock import patch +from django.test import TestCase +from django.db import ProgrammingError +from celery.schedules import crontab + +from allianceauth.crontab.cron import offset_cron +from allianceauth.crontab.models import CronOffset + +logger = logging.getLogger(__name__) + +class TestOffsetCron(TestCase): + + def test_offset_cron_normal(self): + """ + Test that offset_cron modifies the minute/hour fields + based on the CronOffset values when everything is normal. + """ + # We'll create a mock CronOffset instance + mock_offset = CronOffset(minute=0.5, hour=0.5) + + # Our initial crontab schedule + original_schedule = crontab( + minute=[0, 5, 55], + hour=[0, 3, 23], + day_of_month='*', + month_of_year='*', + day_of_week='*' + ) + + # Patch CronOffset.get_solo to return our mock offset + with patch('allianceauth.crontab.models.CronOffset.get_solo', return_value=mock_offset): + new_schedule = offset_cron(original_schedule) + + # Check the new minute/hour + # minute 0 -> 0 + round(60 * 0.5) = 30 % 60 = 30 + # minute 5 -> 5 + 30 = 35 % 60 = 35 + # minute 55 -> 55 + 30 = 85 % 60 = 25 --> sorted => 25,30,35 + self.assertEqual(new_schedule._orig_minute, '25,30,35') + + # hour 0 -> 0 + round(24 * 0.5) = 12 % 24 = 12 + # hour 3 -> 3 + 12 = 15 % 24 = 15 + # hour 23 -> 23 + 12 = 35 % 24 = 11 --> sorted => 11,12,15 + self.assertEqual(new_schedule._orig_hour, '11,12,15') + + # Check that other fields are unchanged + self.assertEqual(new_schedule._orig_day_of_month, '*') + self.assertEqual(new_schedule._orig_month_of_year, '*') + self.assertEqual(new_schedule._orig_day_of_week, '*') + + def test_offset_cron_programming_error(self): + """ + Test that if a ProgrammingError is raised (e.g. before migrations), + offset_cron just returns the original schedule. + """ + original_schedule = crontab(minute=[0, 15, 30], hour=[1, 2, 3]) + + # Force get_solo to raise ProgrammingError + with patch('allianceauth.crontab.models.CronOffset.get_solo', side_effect=ProgrammingError()): + new_schedule = offset_cron(original_schedule) + + # Should return the original schedule unchanged + self.assertEqual(new_schedule, original_schedule) + + def test_offset_cron_unexpected_exception(self): + """ + Test that if any other exception is raised, offset_cron + also returns the original schedule, and logs the error. + """ + original_schedule = crontab(minute='0', hour='0') + + # Force get_solo to raise a generic Exception + with patch('allianceauth.crontab.models.CronOffset.get_solo', side_effect=Exception("Something bad")): + new_schedule = offset_cron(original_schedule) + + # Should return the original schedule unchanged + self.assertEqual(new_schedule, original_schedule) diff --git a/allianceauth/crontab/tests/test_models.py b/allianceauth/crontab/tests/test_models.py new file mode 100644 index 00000000..9f7a63d4 --- /dev/null +++ b/allianceauth/crontab/tests/test_models.py @@ -0,0 +1,66 @@ +import math +from unittest.mock import patch +from django.test import TestCase + +from allianceauth.framework.models import CronOffset # adjust path as needed +from solo.models import SingletonModel + + +class CronOffsetModelTest(TestCase): + def test_cron_offset_is_singleton(self): + """ + Test that CronOffset is indeed a singleton and that + multiple calls to get_solo() return the same instance. + """ + offset1 = CronOffset.get_solo() + offset2 = CronOffset.get_solo() + + # They should be the exact same object in memory + self.assertEqual(offset1.pk, offset2.pk) + self.assertIs(offset1, offset2) + + def test_default_values_random(self): + """ + Test that the default values are set via random_default() when + no explicit value is provided. We'll patch 'random.random' to + produce predictable output. + """ + with patch('allianceauth.framework.models.random', return_value=0.1234): + # Force creation of a new CronOffset by clearing the existing one + CronOffset.objects.all().delete() + + offset = CronOffset.get_solo() # This triggers creation + + # All fields should be 0.1234, because we patched random() + self.assertAlmostEqual(offset.minute, 0.1234) + self.assertAlmostEqual(offset.hour, 0.1234) + self.assertAlmostEqual(offset.day_of_month, 0.1234) + self.assertAlmostEqual(offset.month_of_year, 0.1234) + self.assertAlmostEqual(offset.day_of_week, 0.1234) + + def test_update_offset_values(self): + """ + Test that we can update the offsets and retrieve them. + """ + offset = CronOffset.get_solo() + offset.minute = 0.5 + offset.hour = 0.25 + offset.day_of_month = 0.75 + offset.month_of_year = 0.99 + offset.day_of_week = 0.33 + offset.save() + + # Retrieve again to ensure changes persist + saved_offset = CronOffset.get_solo() + self.assertEqual(saved_offset.minute, 0.5) + self.assertEqual(saved_offset.hour, 0.25) + self.assertEqual(saved_offset.day_of_month, 0.75) + self.assertEqual(saved_offset.month_of_year, 0.99) + self.assertEqual(saved_offset.day_of_week, 0.33) + + def test_str_representation(self): + """ + Verify the __str__ method returns 'Cron Offsets'. + """ + offset = CronOffset.get_solo() + self.assertEqual(str(offset), "Cron Offsets") diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 77897c99..bfe6e8ee 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'allianceauth.theme.flatly', 'allianceauth.theme.materia', "allianceauth.custom_css", + 'allianceauth.crontab', ] SECRET_KEY = "wow I'm a really bad default secret key"