From 903c65d08af6a4a6c194d19a6d01f8dc59329c0b Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 10 Feb 2024 14:33:45 +1100 Subject: [PATCH] Maintenance Mode Update (#6462) * Adjust maintenance mode backend - Save a timestamp to the setting, after which maintenance mode is not active - Fallback to account for possibility that race condition / exception leaves maintenance mode active * Update docstring * Remove unused import * Add unit tests for maintenance mode --- InvenTree/InvenTree/backends.py | 42 ++++++++++++++++++-------- InvenTree/InvenTree/tests.py | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/InvenTree/InvenTree/backends.py b/InvenTree/InvenTree/backends.py index 51fd6e7c18..82fca3fc05 100644 --- a/InvenTree/InvenTree/backends.py +++ b/InvenTree/InvenTree/backends.py @@ -1,5 +1,6 @@ """Custom backend implementations.""" +import datetime import logging import time @@ -8,7 +9,6 @@ from django.db.utils import IntegrityError, OperationalError, ProgrammingError from maintenance_mode.backends import AbstractStateBackend import common.models -import InvenTree.ready logger = logging.getLogger('inventree') @@ -16,8 +16,7 @@ logger = logging.getLogger('inventree') class InvenTreeMaintenanceModeBackend(AbstractStateBackend): """Custom backend for managing state of maintenance mode. - Stores the current state of the maintenance mode in the database, - using an InvenTreeSetting object. + Stores a timestamp in the database to determine when maintenance mode will elapse. """ SETTING_KEY = '_MAINTENANCE_MODE' @@ -30,26 +29,45 @@ class InvenTreeMaintenanceModeBackend(AbstractStateBackend): """ try: setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY) - value = InvenTree.helpers.str2bool(setting.value) + value = str(setting.value).strip() except common.models.InvenTreeSetting.DoesNotExist: # Database is accessible, but setting is not available - assume False - value = False + return False except (IntegrityError, OperationalError, ProgrammingError): # Database is inaccessible - assume we are not in maintenance mode - logger.warning('Failed to read maintenance mode state - assuming True') - value = True + logger.debug('Failed to read maintenance mode state - assuming True') + return True - logger.debug('Maintenance mode state: %s', value) + # Extract timestamp from string + try: + # If the timestamp is in the past, we are now *out* of maintenance mode + timestamp = datetime.datetime.fromisoformat(value) + return timestamp > datetime.datetime.now() + except ValueError: + # If the value is not a valid timestamp, assume maintenance mode is not active + return False - return value + def set_value(self, value: bool, retries: int = 5, minutes: int = 5): + """Set the state of the maintenance mode. - def set_value(self, value: bool, retries: int = 5): - """Set the state of the maintenance mode.""" + Instead of simply writing "true" or "false" to the setting, + we write a timestamp to the setting, which is used to determine + when maintenance mode will elapse. + This ensures that we will always *exit* maintenance mode after a certain time period. + """ logger.debug('Setting maintenance mode state: %s', value) + if value: + # Save as isoformat + timestamp = datetime.datetime.now() + datetime.timedelta(minutes=minutes) + timestamp = timestamp.isoformat() + else: + # Blank timestamp means maintenance mode is not active + timestamp = '' + while retries > 0: try: - common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, value) + common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, timestamp) # Read the value back to confirm if self.get_value() == value: diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 0cda36703b..d110b90d37 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -19,6 +19,7 @@ import pint.errors from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.money import Money +from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode from sesame.utils import get_user import InvenTree.conversion @@ -1264,3 +1265,55 @@ class MagicLoginTest(InvenTreeTestCase): self.assertEqual(resp.url, '/api/auth/login-redirect/') # And we should be logged in again self.assertEqual(resp.wsgi_request.user, self.user) + + +class MaintenanceModeTest(InvenTreeTestCase): + """Unit tests for maintenance mode.""" + + def test_basic(self): + """Test basic maintenance mode operation.""" + for value in [False, True, False]: + set_maintenance_mode(value) + self.assertEqual(get_maintenance_mode(), value) + + # API request is blocked in maintenance mode + set_maintenance_mode(True) + + response = self.client.get('/api/') + self.assertEqual(response.status_code, 503) + + set_maintenance_mode(False) + + response = self.client.get('/api/') + self.assertEqual(response.status_code, 200) + + def test_timestamp(self): + """Test that the timestamp value is interpreted correctly.""" + KEY = '_MAINTENANCE_MODE' + + # Deleting the setting means maintenance mode is off + InvenTreeSetting.objects.filter(key=KEY).delete() + + self.assertFalse(get_maintenance_mode()) + + def set_timestamp(value): + InvenTreeSetting.set_setting(KEY, value, None) + + # Test blank value + set_timestamp('') + self.assertFalse(get_maintenance_mode()) + + # Test timestamp in the past + ts = datetime.now() - timedelta(minutes=10) + set_timestamp(ts.isoformat()) + self.assertFalse(get_maintenance_mode()) + + # Test timestamp in the future + ts = datetime.now() + timedelta(minutes=10) + set_timestamp(ts.isoformat()) + self.assertTrue(get_maintenance_mode()) + + # Set to false, check for empty string + set_maintenance_mode(False) + self.assertFalse(get_maintenance_mode()) + self.assertEqual(InvenTreeSetting.get_setting(KEY, None), '')