2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 22:08:49 +00:00

Update task limiting (#4472)

* Add new setting to control how often we check for new version

* Improved names for settings

* Fix bug in existing backup task

* Refactor backup_task functino

- Add a helper function for running tasks at multi-day intervals

* Refactoring

* Add unit tests for new helper function

* Add multi-day holdoff to "check for updates"

* Allow initial attempt

* Add missing return

* Fixes for unit test
This commit is contained in:
Oliver 2023-03-09 17:46:07 +11:00 committed by GitHub
parent 7372f2b714
commit 06f8a50956
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 186 additions and 61 deletions

View File

@ -74,6 +74,93 @@ def raise_warning(msg):
warnings.warn(msg) warnings.warn(msg)
def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
"""Check if a periodic task should be run, based on the provided setting name.
Arguments:
task_name: The name of the task being run, e.g. 'dummy_task'
setting_name: The name of the global setting, e.g. 'INVENTREE_DUMMY_TASK_INTERVAL'
Returns:
bool: If the task should be run *now*, or wait another day
This function will determine if the task should be run *today*,
based on when it was last run, or if we have a record of it running at all.
Note that this function creates some *hidden* global settings (designated with the _ prefix),
which are used to keep a running track of when the particular task was was last run.
"""
from common.models import InvenTreeSetting
if n_days <= 0:
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
return False
# Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5))
attempt_key = f'_{task_name}_ATTEMPT'
success_key = f'_{task_name}_SUCCESS'
# Check for recent success information
last_success = InvenTreeSetting.get_setting(success_key, '', cache=False)
if last_success:
try:
last_success = datetime.fromisoformat(last_success)
except ValueError:
last_success = None
if last_success:
threshold = datetime.now() - timedelta(days=n_days)
if last_success > threshold:
logger.info(f"Last successful run for '{task_name}' was too recent - skipping task")
return False
# Check for any information we have about this task
last_attempt = InvenTreeSetting.get_setting(attempt_key, '', cache=False)
if last_attempt:
try:
last_attempt = datetime.fromisoformat(last_attempt)
except ValueError:
last_attempt = None
if last_attempt:
# Do not attempt if the most recent *attempt* was within 12 hours
threshold = datetime.now() - timedelta(hours=12)
if last_attempt > threshold:
logger.info(f"Last attempt for '{task_name}' was too recent - skipping task")
return False
# Record this attempt
record_task_attempt(task_name)
# No reason *not* to run this task now
return True
def record_task_attempt(task_name: str):
"""Record that a multi-day task has been attempted *now*"""
from common.models import InvenTreeSetting
logger.info(f"Logging task attempt for '{task_name}'")
InvenTreeSetting.set_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None)
def record_task_success(task_name: str):
"""Record that a multi-day task was successful *now*"""
from common.models import InvenTreeSetting
InvenTreeSetting.set_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs): def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs):
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once! """Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
@ -348,6 +435,14 @@ def check_for_updates():
logger.info("Could not perform 'check_for_updates' - App registry not ready") logger.info("Could not perform 'check_for_updates' - App registry not ready")
return return
interval = int(common.models.InvenTreeSetting.get_setting('INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False))
# Check if we should check for updates *today*
if not check_daily_holdoff('check_for_updates', interval):
return
logger.info("Checking for InvenTree software updates")
headers = {} headers = {}
# If running within github actions, use authentication token # If running within github actions, use authentication token
@ -357,7 +452,10 @@ def check_for_updates():
if token: if token:
headers['Authorization'] = f"Bearer {token}" headers['Authorization'] = f"Bearer {token}"
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest', headers=headers) response = requests.get(
'https://api.github.com/repos/inventree/inventree/releases/latest',
headers=headers
)
if response.status_code != 200: if response.status_code != 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
@ -389,6 +487,9 @@ def check_for_updates():
None None
) )
# Record that this task was successful
record_task_success('check_for_updates')
@scheduled_task(ScheduledTask.DAILY) @scheduled_task(ScheduledTask.DAILY)
def update_exchange_rates(): def update_exchange_rates():
@ -435,68 +536,26 @@ def update_exchange_rates():
@scheduled_task(ScheduledTask.DAILY) @scheduled_task(ScheduledTask.DAILY)
def run_backup(): def run_backup():
"""Run the backup command.""" """Run the backup command."""
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False): if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
# Backups are not enabled - exit early # Backups are not enabled - exit early
return return
logger.info("Performing automated database backup task") interval = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
# Sleep a random number of seconds to prevent worker conflict # Check if should run this task *today*
time.sleep(random.randint(1, 5)) if not check_daily_holdoff('run_backup', interval):
# Check for records of previous backup attempts
last_attempt = InvenTreeSetting.get_setting('_INVENTREE_BACKUP_ATTEMPT', '', cache=False)
last_success = InvenTreeSetting.get_setting('_INVENTREE_BACKUP_SUCCESS', '', cache=False)
try:
backup_n_days = int(InvenTreeSetting.get_setting('_INVENTREE_BACKUP_DAYS', 1, cache=False))
except Exception:
backup_n_days = 1
if last_attempt:
try:
last_attempt = datetime.fromisoformat(last_attempt)
except ValueError:
last_attempt = None
if last_attempt:
# Do not attempt if the 'last attempt' at backup was within 12 hours
threshold = datetime.now() - timedelta(hours=12)
if last_attempt > threshold:
logger.info('Last backup attempt was too recent - skipping backup operation')
return
# Record the timestamp of most recent backup attempt
InvenTreeSetting.set_setting('_INVENTREE_BACKUP_ATTEMPT', datetime.now().isoformat(), None)
if not last_attempt:
# If there is no record of a previous attempt, exit quickly
# This prevents the backup operation from happening when the server first launches, for example
logger.info("No previous backup attempts recorded - waiting until tomorrow")
return return
if last_success: logger.info("Performing automated database backup task")
try:
last_success = datetime.fromisoformat(last_success)
except ValueError:
last_success = None
# Exit early if the backup was successful within the number of required days
if last_success:
threshold = datetime.now() - timedelta(days=backup_n_days)
if last_success > threshold:
logger.info('Last successful backup was too recent - skipping backup operation')
return
call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False) call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False)
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False) call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
# Record the timestamp of most recent backup success # Record that this task was successful
InvenTreeSetting.set_setting('_INVENTREE_BACKUP_SUCCESS', datetime.now().isoformat(), None) record_task_success('run_backup')
def send_email(subject, body, recipients, from_email=None, html_message=None): def send_email(subject, body, recipients, from_email=None, html_message=None):

View File

@ -3,6 +3,7 @@
import json import json
import os import os
import time import time
from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from unittest import mock from unittest import mock
@ -906,6 +907,58 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
force_async=True force_async=True
) )
def test_daily_holdoff(self):
"""Tests for daily task holdoff helper functions"""
import InvenTree.tasks
with self.assertLogs(logger='inventree', level='INFO') as cm:
# With a non-positive interval, task will not run
result = InvenTree.tasks.check_daily_holdoff('some_task', 0)
self.assertFalse(result)
self.assertIn('Specified interval', str(cm.output))
with self.assertLogs(logger='inventree', level='INFO') as cm:
# First call should run without issue
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
self.assertTrue(result)
self.assertIn("Logging task attempt for 'dummy_task'", str(cm.output))
with self.assertLogs(logger='inventree', level='INFO') as cm:
# An attempt has been logged, but it is too recent
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
self.assertFalse(result)
self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output))
# Mark last attempt a few days ago - should now return True
t_old = datetime.now() - timedelta(days=3)
t_old = t_old.isoformat()
InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None)
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 5)
self.assertTrue(result)
# Last attempt should have been updated
self.assertNotEqual(t_old, InvenTreeSetting.get_setting('_dummy_task_ATTEMPT', '', cache=False))
# Last attempt should prevent us now
with self.assertLogs(logger='inventree', level='INFO') as cm:
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
self.assertFalse(result)
self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output))
# Configure so a task was successful too recently
InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None)
InvenTreeSetting.set_setting('_dummy_task_SUCCESS', t_old, None)
with self.assertLogs(logger='inventree', level='INFO') as cm:
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 7)
self.assertFalse(result)
self.assertIn('Last successful run for', str(cm.output))
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 2)
self.assertTrue(result)
class BarcodeMixinTest(helpers.InvenTreeTestCase): class BarcodeMixinTest(helpers.InvenTreeTestCase):
"""Tests for the InvenTreeBarcodeMixin mixin class""" """Tests for the InvenTreeBarcodeMixin mixin class"""

View File

@ -984,6 +984,17 @@ class InvenTreeSetting(BaseInvenTreeSetting):
] ]
}, },
'INVENTREE_UPDATE_CHECK_INTERVAL': {
'name': _('Update Check Inverval'),
'description': _('How often to check for updates (set to zero to disable)'),
'validator': [
int,
MinValueValidator(0),
],
'default': 7,
'units': _('days'),
},
'INVENTREE_BACKUP_ENABLE': { 'INVENTREE_BACKUP_ENABLE': {
'name': _('Automatic Backup'), 'name': _('Automatic Backup'),
'description': _('Enable automatic backup of database and media files'), 'description': _('Enable automatic backup of database and media files'),
@ -992,20 +1003,21 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'INVENTREE_BACKUP_DAYS': { 'INVENTREE_BACKUP_DAYS': {
'name': _('Days Between Backup'), 'name': _('Auto Backup Interval'),
'description': _('Specify number of days between automated backup events'), 'description': _('Specify number of days between automated backup events'),
'validator': [ 'validator': [
int, int,
MinValueValidator(1), MinValueValidator(1),
], ],
'default': 1, 'default': 1,
'units': _('days'),
}, },
'INVENTREE_DELETE_TASKS_DAYS': { 'INVENTREE_DELETE_TASKS_DAYS': {
'name': _('Delete Old Tasks'), 'name': _('Task Deletion Interval'),
'description': _('Background task results will be deleted after specified number of days'), 'description': _('Background task results will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7), MinValueValidator(7),
@ -1013,10 +1025,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'INVENTREE_DELETE_ERRORS_DAYS': { 'INVENTREE_DELETE_ERRORS_DAYS': {
'name': _('Delete Error Logs'), 'name': _('Error Log Deletion Interval'),
'description': _('Error logs will be deleted after specified number of days'), 'description': _('Error logs will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7) MinValueValidator(7)
@ -1024,10 +1036,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'INVENTREE_DELETE_NOTIFICATIONS_DAYS': { 'INVENTREE_DELETE_NOTIFICATIONS_DAYS': {
'name': _('Delete Notifications'), 'name': _('Notification Deletion Interval'),
'description': _('User notifications will be deleted after specified number of days'), 'description': _('User notifications will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7), MinValueValidator(7),
@ -1233,7 +1245,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Stock Item Pricing Age'), 'name': _('Stock Item Pricing Age'),
'description': _('Exclude stock items older than this number of days from pricing calculations'), 'description': _('Exclude stock items older than this number of days from pricing calculations'),
'default': 0, 'default': 0,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(0), MinValueValidator(0),
@ -1255,7 +1267,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'PRICING_UPDATE_DAYS': { 'PRICING_UPDATE_DAYS': {
'name': _('Pricing Rebuild Time'), 'name': _('Pricing Rebuild Interval'),
'description': _('Number of days before part pricing is automatically updated'), 'description': _('Number of days before part pricing is automatically updated'),
'units': _('days'), 'units': _('days'),
'default': 30, 'default': 30,
@ -1598,10 +1610,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'STOCKTAKE_DELETE_REPORT_DAYS': { 'STOCKTAKE_DELETE_REPORT_DAYS': {
'name': _('Delete Old Reports'), 'name': _('Report Deletion Interval'),
'description': _('Stocktake reports will be deleted after specified number of days'), 'description': _('Stocktake reports will be deleted after specified number of days'),
'default': 30, 'default': 30,
'units': 'days', 'units': _('days'),
'validator': [ 'validator': [
int, int,
MinValueValidator(7), MinValueValidator(7),

View File

@ -19,6 +19,7 @@
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_RESTRICT_ABOUT" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_RESTRICT_ABOUT" icon="fa-info-circle" %}
<tr><td colspan='5'></td></tr> <tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_UPDATE_CHECK_INTERVAL" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE" icon="fa-server" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE" icon="fa-server" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT" icon="fa-server" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT" icon="fa-server" %}