mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2026-02-17 12:36:22 +01:00
Compare commits
7 Commits
v5.x
...
a66aa6de80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66aa6de80 | ||
|
|
d12d6e7cdb | ||
|
|
7022cb7050 | ||
|
|
30a79362f4 | ||
|
|
c3fa8acd8e | ||
|
|
8fd1411f09 | ||
|
|
1aa90adac3 |
@@ -1,5 +1,5 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.core.checks import Warning, Error, register
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
|
||||||
class AllianceAuthConfig(AppConfig):
|
class AllianceAuthConfig(AppConfig):
|
||||||
@@ -7,3 +7,48 @@ class AllianceAuthConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
import allianceauth.checks # noqa
|
import allianceauth.checks # noqa
|
||||||
|
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
||||||
|
from allianceauth.crontab.cron import offset_cron
|
||||||
|
|
||||||
|
PeriodicTask.objects.update_or_create(
|
||||||
|
name='esi_cleanup_callbackredirect',
|
||||||
|
defaults={
|
||||||
|
'task': 'esi.tasks.cleanup_callbackredirect',
|
||||||
|
'crontab': CrontabSchedule.objects.get_or_create(minute='0', hour='0', day_of_week='*', day_of_month='*', month_of_year='*', timezone='UTC')[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
PeriodicTask.objects.update_or_create(
|
||||||
|
name='esi_cleanup_token',
|
||||||
|
defaults={
|
||||||
|
'task': 'esi.tasks.cleanup_token',
|
||||||
|
'crontab': CrontabSchedule.objects.get_or_create(minute='0', hour='0', day_of_week='*', day_of_month='*', month_of_year='*', timezone='UTC')[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
z = CrontabSchedule.from_schedule(offset_cron(crontab(minute='0', hour='*/6')))
|
||||||
|
PeriodicTask.objects.update_or_create(
|
||||||
|
name='run_model_update',
|
||||||
|
defaults={
|
||||||
|
'task': 'allianceauth.eveonline.tasks.run_model_update',
|
||||||
|
'crontab': CrontabSchedule.objects.get_or_create( # Convert the offsetted cron into a DB object
|
||||||
|
minute=z.minute, hour=z.hour, day_of_week=z.day_of_week, day_of_month=z.day_of_month, month_of_year=z.month_of_year, timezone=z.timezone)[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
z = CrontabSchedule.from_schedule(offset_cron(crontab(minute='0', hour='*/4')))
|
||||||
|
PeriodicTask.objects.update_or_create(
|
||||||
|
name='check_all_character_ownership',
|
||||||
|
defaults={
|
||||||
|
'task': 'allianceauth.authentication.tasks.check_all_character_ownership',
|
||||||
|
'crontab': CrontabSchedule.objects.get_or_create( # Convert the offsetted cron into a DB object
|
||||||
|
minute=z.minute, hour=z.hour, day_of_week=z.day_of_week, day_of_month=z.day_of_month, month_of_year=z.month_of_year, timezone=z.timezone)[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
PeriodicTask.objects.update_or_create(
|
||||||
|
name='analytics_daily_stats',
|
||||||
|
defaults={
|
||||||
|
'task': 'allianceauth.analytics.tasks.analytics_daily_stats',
|
||||||
|
'crontab': CrontabSchedule.objects.get_or_create(
|
||||||
|
minute='0', hour='12', day_of_week='*', day_of_month='*', month_of_year='*', timezone='UTC')[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
3
allianceauth/crontab/__init__.py
Normal file
3
allianceauth/crontab/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Alliance Auth Crontab Utilities
|
||||||
|
"""
|
||||||
14
allianceauth/crontab/apps.py
Normal file
14
allianceauth/crontab/apps.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Crontab App Config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CrontabConfig(AppConfig):
|
||||||
|
"""
|
||||||
|
Crontab App Config
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "allianceauth.crontab"
|
||||||
|
label = "crontab"
|
||||||
40
allianceauth/crontab/cron.py
Normal file
40
allianceauth/crontab/cron.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from celery.schedules import crontab
|
||||||
|
import logging
|
||||||
|
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
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule (crontab): celery.schedules.crontab()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
crontab: A crontab with offsetted Minute and Hour fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cron_offset = CronOffset.get_solo()
|
||||||
|
new_minute = [(m + (round(60 * cron_offset.minute))) % 60 for m in schedule.minute]
|
||||||
|
new_hour = [(m + (round(24 * cron_offset.hour))) % 24 for m in schedule.hour]
|
||||||
|
|
||||||
|
return crontab(
|
||||||
|
minute=",".join(str(m) for m in sorted(new_minute)),
|
||||||
|
hour=",".join(str(h) for h in sorted(new_hour)),
|
||||||
|
day_of_month=schedule._orig_day_of_month,
|
||||||
|
month_of_year=schedule._orig_month_of_year,
|
||||||
|
day_of_week=schedule._orig_day_of_week)
|
||||||
|
|
||||||
|
except ProgrammingError as e:
|
||||||
|
# If this is called before migrations are run hand back the default schedule
|
||||||
|
# These offsets are stored in a Singleton Model,
|
||||||
|
logger.error(e)
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# We absolutely cant fail to hand back a schedule
|
||||||
|
logger.error(e)
|
||||||
|
return schedule
|
||||||
23
allianceauth/crontab/models.py
Normal file
23
allianceauth/crontab/models.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from random import random
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from solo.models import SingletonModel
|
||||||
|
|
||||||
|
|
||||||
|
def random_default() -> float:
|
||||||
|
return random()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
day_of_week = models.FloatField(_("Day of Week Offset"), default=random_default)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Cron Offsets"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Cron Offsets"
|
||||||
0
allianceauth/crontab/tests/__init__.py
Normal file
0
allianceauth/crontab/tests/__init__.py
Normal file
79
allianceauth/crontab/tests/test_cron.py
Normal file
79
allianceauth/crontab/tests/test_cron.py
Normal file
@@ -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)
|
||||||
64
allianceauth/crontab/tests/test_models.py
Normal file
64
allianceauth/crontab/tests/test_models.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.crontab.models import CronOffset
|
||||||
|
|
||||||
|
|
||||||
|
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.crontab.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")
|
||||||
0
allianceauth/framework/migrations/__init__.py
Normal file
0
allianceauth/framework/migrations/__init__.py
Normal file
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
'allianceauth.theme.flatly',
|
'allianceauth.theme.flatly',
|
||||||
'allianceauth.theme.materia',
|
'allianceauth.theme.materia',
|
||||||
"allianceauth.custom_css",
|
"allianceauth.custom_css",
|
||||||
|
'allianceauth.crontab',
|
||||||
]
|
]
|
||||||
|
|
||||||
SECRET_KEY = "wow I'm a really bad default secret key"
|
SECRET_KEY = "wow I'm a really bad default secret key"
|
||||||
@@ -50,28 +51,7 @@ SECRET_KEY = "wow I'm a really bad default secret key"
|
|||||||
# Celery configuration
|
# Celery configuration
|
||||||
BROKER_URL = 'redis://localhost:6379/0'
|
BROKER_URL = 'redis://localhost:6379/0'
|
||||||
CELERYBEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
|
CELERYBEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
|
||||||
CELERYBEAT_SCHEDULE = {
|
CELERYBEAT_SCHEDULE = {}
|
||||||
'esi_cleanup_callbackredirect': {
|
|
||||||
'task': 'esi.tasks.cleanup_callbackredirect',
|
|
||||||
'schedule': crontab(minute='0', hour='*/4'),
|
|
||||||
},
|
|
||||||
'esi_cleanup_token': {
|
|
||||||
'task': 'esi.tasks.cleanup_token',
|
|
||||||
'schedule': crontab(minute='0', hour='0'),
|
|
||||||
},
|
|
||||||
'run_model_update': {
|
|
||||||
'task': 'allianceauth.eveonline.tasks.run_model_update',
|
|
||||||
'schedule': crontab(minute='0', hour="*/6"),
|
|
||||||
},
|
|
||||||
'check_all_character_ownership': {
|
|
||||||
'task': 'allianceauth.authentication.tasks.check_all_character_ownership',
|
|
||||||
'schedule': crontab(minute='0', hour='*/4'),
|
|
||||||
},
|
|
||||||
'analytics_daily_stats': {
|
|
||||||
'task': 'allianceauth.analytics.tasks.analytics_daily_stats',
|
|
||||||
'schedule': crontab(minute='0', hour='2'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|||||||
Reference in New Issue
Block a user