mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-06 12:01:41 +00:00
chore(backend): increase coverage (#10121)
* chore(backend): increase coverage * add a full api based install / uninstall test * fix asserted code * delete unreachable code * clean up unused code * add more notification tests * fix test * order currencies
This commit is contained in:
@@ -5,11 +5,7 @@ from django.apps import AppConfig
|
||||
import structlog
|
||||
|
||||
import InvenTree.ready
|
||||
from common.settings import (
|
||||
get_global_setting,
|
||||
global_setting_overrides,
|
||||
set_global_setting,
|
||||
)
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
@@ -24,11 +20,10 @@ class CommonConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""Initialize restart flag clearance on startup."""
|
||||
if InvenTree.ready.isRunningMigrations():
|
||||
if InvenTree.ready.isRunningMigrations(): # pragma: no cover
|
||||
return
|
||||
|
||||
self.clear_restart_flag()
|
||||
self.override_global_settings()
|
||||
|
||||
def clear_restart_flag(self):
|
||||
"""Clear the SERVER_RESTART_REQUIRED setting."""
|
||||
@@ -40,28 +35,5 @@ class CommonConfig(AppConfig):
|
||||
|
||||
if not InvenTree.ready.isImportingData():
|
||||
set_global_setting('SERVER_RESTART_REQUIRED', False, None)
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
def override_global_settings(self):
|
||||
"""Update global settings based on environment variables."""
|
||||
overrides = global_setting_overrides()
|
||||
|
||||
if not overrides:
|
||||
return
|
||||
|
||||
for key, value in overrides.items():
|
||||
try:
|
||||
current_value = get_global_setting(key, create=False)
|
||||
|
||||
if current_value != value:
|
||||
logger.info(
|
||||
'INVE-I1: Overriding global setting: %s = %s',
|
||||
value,
|
||||
current_value,
|
||||
)
|
||||
set_global_setting(key, value, None, create=True)
|
||||
|
||||
except Exception:
|
||||
logger.warning('Failed to override global setting %s -> %s', key, value)
|
||||
continue
|
||||
|
@@ -18,13 +18,13 @@ class CreateModelOrSkip(migrations.CreateModel):
|
||||
|
||||
try:
|
||||
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
def state_forwards(self, app_label, state) -> None:
|
||||
try:
|
||||
super().state_forwards(app_label, state)
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
|
@@ -49,18 +49,18 @@ def set_currencies(apps, schema_editor):
|
||||
|
||||
value = ','.join(valid_codes)
|
||||
|
||||
if not settings.TESTING:
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
print(f"Found existing currency codes:", value)
|
||||
|
||||
setting = InvenTreeSetting.objects.filter(key=key).first()
|
||||
|
||||
if setting:
|
||||
if not settings.TESTING:
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
print(f"- Updating existing setting for currency codes")
|
||||
setting.value = value
|
||||
setting.save()
|
||||
else:
|
||||
if not settings.TESTING:
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
print(f"- Creating new setting for currency codes")
|
||||
setting = InvenTreeSetting(key=key, value=value)
|
||||
setting.save()
|
||||
|
@@ -288,7 +288,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
try:
|
||||
cache.set(key, self, timeout=3600)
|
||||
except Exception:
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@@ -558,7 +558,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
or InvenTree.ready.isRunningMigrations()
|
||||
or InvenTree.ready.isRebuildingData()
|
||||
or InvenTree.ready.isRunningBackup()
|
||||
):
|
||||
): # pragma: no cover
|
||||
create = False
|
||||
access_global_cache = False
|
||||
|
||||
@@ -706,7 +706,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
or InvenTree.ready.isRunningMigrations()
|
||||
or InvenTree.ready.isRebuildingData()
|
||||
or InvenTree.ready.isRunningBackup()
|
||||
):
|
||||
): # pragma: no cover
|
||||
return
|
||||
|
||||
attempts = int(kwargs.get('attempts', 3))
|
||||
@@ -731,7 +731,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
logger.warning("Database is locked, cannot set setting '%s'", key)
|
||||
# Likely the DB is locked - not much we can do here
|
||||
return
|
||||
except Exception as exc:
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception(
|
||||
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))
|
||||
)
|
||||
@@ -766,7 +766,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
except (OperationalError, ProgrammingError):
|
||||
logger.warning("Database is locked, cannot set setting '%s'", key)
|
||||
# Likely the DB is locked - not much we can do here
|
||||
except Exception as exc:
|
||||
except Exception as exc: # pragma: no cover
|
||||
# Some other error
|
||||
logger.exception(
|
||||
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))
|
||||
|
@@ -22,92 +22,6 @@ logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
# region methods
|
||||
class NotificationMethod:
|
||||
"""Base class for notification methods."""
|
||||
|
||||
METHOD_NAME = ''
|
||||
METHOD_ICON = None
|
||||
CONTEXT_BUILTIN = ['name', 'message']
|
||||
CONTEXT_EXTRA = []
|
||||
GLOBAL_SETTING = None
|
||||
USER_SETTING = None
|
||||
|
||||
def __init__(self, obj: Model, category: str, targets: list, context) -> None:
|
||||
"""Check that the method is read.
|
||||
|
||||
This checks that:
|
||||
- All needed functions are implemented
|
||||
- The method is not disabled via plugin
|
||||
- All needed context values were provided
|
||||
"""
|
||||
# Check if a sending fnc is defined
|
||||
if (not hasattr(self, 'send')) and (not hasattr(self, 'send_bulk')):
|
||||
raise NotImplementedError(
|
||||
'A NotificationMethod must either define a `send` or a `send_bulk` method'
|
||||
)
|
||||
|
||||
# No method name is no good
|
||||
if self.METHOD_NAME in ('', None):
|
||||
raise NotImplementedError(
|
||||
f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME'
|
||||
)
|
||||
|
||||
# Check if plugin is disabled - if so do not gather targets etc.
|
||||
if self.global_setting_disable():
|
||||
self.targets = None
|
||||
return
|
||||
|
||||
# Define arguments
|
||||
self.obj = obj
|
||||
self.category = category
|
||||
self.targets = targets
|
||||
self.context = self.check_context(context)
|
||||
|
||||
# Gather targets
|
||||
self.targets = self.get_targets()
|
||||
|
||||
def check_context(self, context):
|
||||
"""Check that all values defined in the methods CONTEXT were provided in the current context."""
|
||||
|
||||
def check(ref, obj):
|
||||
# the obj is not accessible so we are on the end
|
||||
if not isinstance(obj, (list, dict, tuple)):
|
||||
return ref
|
||||
|
||||
# check if the ref exists
|
||||
if isinstance(ref, str):
|
||||
if not obj.get(ref):
|
||||
return ref
|
||||
return False
|
||||
|
||||
# nested
|
||||
elif isinstance(ref, (tuple, list)):
|
||||
if len(ref) == 1:
|
||||
return check(ref[0], obj)
|
||||
ret = check(ref[0], obj)
|
||||
if ret:
|
||||
return ret
|
||||
return check(ref[1:], obj[ref[0]])
|
||||
|
||||
# other cases -> raise
|
||||
raise NotImplementedError(
|
||||
'This type can not be used as a context reference'
|
||||
)
|
||||
|
||||
missing = []
|
||||
for item in (*self.CONTEXT_BUILTIN, *self.CONTEXT_EXTRA):
|
||||
ret = check(item, context)
|
||||
if ret:
|
||||
missing.append(ret)
|
||||
|
||||
if missing:
|
||||
raise NotImplementedError(
|
||||
f'The `context` is missing the following items:\n{missing}'
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@dataclass()
|
||||
class NotificationBody:
|
||||
"""Information needed to create a notification.
|
||||
@@ -182,7 +96,7 @@ def trigger_notification(
|
||||
kwargs: Additional arguments to pass to the notification method
|
||||
"""
|
||||
# Check if data is importing currently
|
||||
if isImportingData() or isRebuildingData():
|
||||
if isImportingData() or isRebuildingData(): # pragma: no cover
|
||||
return
|
||||
|
||||
targets = kwargs.get('targets')
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Data migration unit tests for the 'common' app."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
@@ -208,3 +209,49 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
'stockitem',
|
||||
]:
|
||||
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)
|
||||
|
||||
|
||||
def prep_currency_migration(self, vals: str):
|
||||
"""Prepare the environment for the currency migration tests."""
|
||||
# Set keys
|
||||
os.environ['INVENTREE_CURRENCIES'] = vals
|
||||
|
||||
# And setting
|
||||
InvenTreeSetting = self.old_state.apps.get_model('common', 'InvenTreeSetting')
|
||||
|
||||
setting = InvenTreeSetting(key='CURRENCY_CODES', value='123')
|
||||
setting.save()
|
||||
|
||||
|
||||
class TestCurrencyMigration(MigratorTestCase):
|
||||
"""Test currency migration."""
|
||||
|
||||
migrate_from = ('common', '0022_projectcode_responsible')
|
||||
migrate_to = ('common', '0023_auto_20240602_1332')
|
||||
|
||||
def prepare(self):
|
||||
"""Prepare the environment for the migration test."""
|
||||
prep_currency_migration(self, 'USD,EUR,GBP')
|
||||
|
||||
def test_currency_migration(self):
|
||||
"""Test that the currency migration works."""
|
||||
InvenTreeSetting = self.old_state.apps.get_model('common', 'InvenTreeSetting')
|
||||
setting = InvenTreeSetting.objects.filter(key='CURRENCY_CODES').first()
|
||||
self.assertEqual(setting.value, 'EUR,GBP,USD')
|
||||
|
||||
|
||||
class TestCurrencyMigrationNo(MigratorTestCase):
|
||||
"""Test currency migration."""
|
||||
|
||||
migrate_from = ('common', '0022_projectcode_responsible')
|
||||
migrate_to = ('common', '0023_auto_20240602_1332')
|
||||
|
||||
def prepare(self):
|
||||
"""Prepare the environment for the migration test."""
|
||||
prep_currency_migration(self, 'YYY,ZZZ')
|
||||
|
||||
def test_currency_migration(self):
|
||||
"""Test that no currency migration occurs if wrong currencies are set."""
|
||||
InvenTreeSetting = self.old_state.apps.get_model('common', 'InvenTreeSetting')
|
||||
setting = InvenTreeSetting.objects.filter(key='CURRENCY_CODES').first()
|
||||
self.assertEqual(setting.value, '123')
|
||||
|
@@ -8,6 +8,7 @@ from http import HTTPStatus
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -21,6 +22,7 @@ from django.urls import reverse
|
||||
import PIL
|
||||
|
||||
import common.validators
|
||||
from common.notifications import trigger_notification
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.unit_test import (
|
||||
@@ -1073,6 +1075,7 @@ class NotificationTest(InvenTreeAPITestCase):
|
||||
"""Tests for NotificationEntry."""
|
||||
|
||||
fixtures = ['users']
|
||||
roles = ['admin.view']
|
||||
|
||||
def test_check_notification_entries(self):
|
||||
"""Test that notification entries can be created."""
|
||||
@@ -1162,6 +1165,72 @@ class NotificationTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(NotificationMessage.objects.count(), 13)
|
||||
self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3)
|
||||
|
||||
def test_simple(self):
|
||||
"""Test that a simple notification can be created."""
|
||||
trigger_notification(
|
||||
Group.objects.get(name='Sales'),
|
||||
user=self.user,
|
||||
data={'message': 'This is a test notification'},
|
||||
)
|
||||
|
||||
def test_with_group(self):
|
||||
"""Test that a notification can be created with a group."""
|
||||
grp = Group.objects.get(name='Sales')
|
||||
trigger_notification(
|
||||
grp,
|
||||
user=self.user,
|
||||
data={'message': 'This is a test notification with group'},
|
||||
targets=[grp],
|
||||
)
|
||||
|
||||
def test_wrong_target(self):
|
||||
"""Test that a notification with an invalid target raises an error."""
|
||||
with self.assertLogs() as cm:
|
||||
trigger_notification(
|
||||
Group.objects.get(name='Sales'),
|
||||
user=self.user,
|
||||
data={'message': 'This is a test notification'},
|
||||
targets=['invalid_target'],
|
||||
)
|
||||
self.assertIn('Unknown target passed to t', str(cm[1]))
|
||||
|
||||
def test_wrong_obj(self):
|
||||
"""Test that a object without a reference is raising an issue."""
|
||||
|
||||
class SampleObj:
|
||||
pass
|
||||
|
||||
with self.assertRaises(KeyError) as cm:
|
||||
trigger_notification(
|
||||
SampleObj(),
|
||||
user=self.user,
|
||||
data={'message': 'This is a test notification'},
|
||||
)
|
||||
self.assertIn('Could not resolve an object reference for', str(cm.exception))
|
||||
|
||||
# Without reference, it should not raise an error
|
||||
trigger_notification(
|
||||
Group.objects.get(name='Sales'),
|
||||
user=self.user,
|
||||
data={'message': 'This is a test notification'},
|
||||
)
|
||||
|
||||
def test_recent(self):
|
||||
"""Test that a notification is not created if it was already sent recently."""
|
||||
grp = Group.objects.get(name='Sales')
|
||||
trigger_notification( #
|
||||
grp, category='core', context={'name': 'test'}, targets=[self.user]
|
||||
)
|
||||
self.assertEqual(NotificationMessage.objects.count(), 1)
|
||||
|
||||
# Should not create a new notification
|
||||
with self.assertLogs(logger='inventree') as cm:
|
||||
trigger_notification(
|
||||
grp, category='core', context={'name': 'test'}, targets=[self.user]
|
||||
)
|
||||
self.assertEqual(NotificationMessage.objects.count(), 1)
|
||||
self.assertIn('as recently been sent for', str(cm[1]))
|
||||
|
||||
|
||||
class CommonTest(InvenTreeAPITestCase):
|
||||
"""Tests for the common config."""
|
||||
|
@@ -32,6 +32,7 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
|
||||
"""Create a UI notification entry for specified users."""
|
||||
from common.models import NotificationMessage
|
||||
|
||||
ctx = context if context else {}
|
||||
entries = []
|
||||
|
||||
if not users:
|
||||
@@ -45,8 +46,8 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
|
||||
source_object=user,
|
||||
user=user,
|
||||
category=category,
|
||||
name=context['name'],
|
||||
message=context['message'],
|
||||
name=ctx.get('name'),
|
||||
message=ctx.get('message'),
|
||||
)
|
||||
)
|
||||
|
||||
|
@@ -594,3 +594,55 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(Y_MANDATORY_2, Y_MANDATORY + 1)
|
||||
self.assertEqual(N_MANDATORY_2, N_MANDATORY - 1)
|
||||
|
||||
|
||||
class PluginFullAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
"""Tests the plugin API endpoints."""
|
||||
|
||||
superuser = True
|
||||
|
||||
@override_settings(PLUGIN_TESTING_SETUP=True)
|
||||
def test_full_process(self):
|
||||
"""Test the full plugin install/uninstall process via API."""
|
||||
install_slug = 'inventree-brother-plugin'
|
||||
slug = 'brother'
|
||||
|
||||
# Install a plugin
|
||||
data = self.post(
|
||||
reverse('api-plugin-install'),
|
||||
{'confirm': True, 'packagename': install_slug},
|
||||
expected_code=201,
|
||||
max_query_time=30,
|
||||
max_query_count=370,
|
||||
).data
|
||||
self.assertEqual(data['success'], 'Installed plugin successfully')
|
||||
|
||||
# Activate the plugin
|
||||
data = self.patch(
|
||||
reverse('api-plugin-detail-activate', kwargs={'plugin': slug}),
|
||||
data={'active': True},
|
||||
max_query_count=320,
|
||||
).data
|
||||
self.assertEqual(data['active'], True)
|
||||
|
||||
# Check if the plugin is installed
|
||||
test_plg = PluginConfig.objects.get(key=slug)
|
||||
self.assertIsNotNone(test_plg, 'Test plugin not found')
|
||||
self.assertTrue(test_plg.is_active())
|
||||
|
||||
# De-activate and uninstall the plugin
|
||||
data = self.patch(
|
||||
reverse('api-plugin-detail-activate', kwargs={'plugin': slug}),
|
||||
data={'active': False},
|
||||
max_query_count=380,
|
||||
).data
|
||||
self.assertEqual(data['active'], False)
|
||||
response = self.patch(
|
||||
reverse('api-plugin-uninstall', kwargs={'plugin': slug}),
|
||||
max_query_count=305,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Successful uninstallation
|
||||
with self.assertRaises(PluginConfig.DoesNotExist):
|
||||
PluginConfig.objects.get(key=slug)
|
||||
|
Reference in New Issue
Block a user