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:
parent
7372f2b714
commit
06f8a50956
@ -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):
|
||||||
|
@ -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"""
|
||||||
|
@ -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),
|
||||||
|
@ -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" %}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user