2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Refactor: Move settings definitions (and most validators) out of models.py (#8646)

* move out settings definitions

* fix import paths

* move validators into appropiate settings

* fix refactor error

* fix import paths

* move types out

* add validator tests

* add option to reload internal issues

* fix tests
This commit is contained in:
Matthias Mair 2024-12-13 01:57:11 +01:00 committed by GitHub
parent dd83735710
commit 7bfd36f7cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1589 additions and 1500 deletions

View File

@ -1,15 +1,13 @@
"""Custom field validators for InvenTree.""" """Custom field validators for InvenTree."""
import re
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.conf import settings from django.conf import settings
from django.core import validators from django.core import validators
from django.core.exceptions import FieldDoesNotExist, ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import pint import pint
from jinja2 import Template
from moneyed import CURRENCIES from moneyed import CURRENCIES
import InvenTree.conversion import InvenTree.conversion
@ -137,41 +135,3 @@ def validate_overage(value):
pass pass
raise ValidationError(_('Invalid value for overage')) raise ValidationError(_('Invalid value for overage'))
def validate_part_name_format(value):
"""Validate part name format.
Make sure that each template container has a field of Part Model
"""
# Make sure that the field_name exists in Part model
from part.models import Part
jinja_template_regex = re.compile('{{.*?}}')
field_name_regex = re.compile('(?<=part\\.)[A-z]+')
for jinja_template in jinja_template_regex.findall(str(value)):
# make sure at least one and only one field is present inside the parser
field_names = field_name_regex.findall(jinja_template)
if len(field_names) < 1:
raise ValidationError({
'value': 'At least one field must be present inside a jinja template container i.e {{}}'
})
for field_name in field_names:
try:
Part._meta.get_field(field_name)
except FieldDoesNotExist:
raise ValidationError({
'value': f'{field_name} does not exist in Part Model'
})
# Attempt to render the template with a dummy Part instance
p = Part(name='test part', description='some test part')
try:
Template(value).render({'part': p})
except Exception as exc:
raise ValidationError({'value': str(exc)})
return True

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
"""Tests for the various validators in the settings."""
from django.core.exceptions import ValidationError
from django.test import TestCase
import common.setting.system
class SettingsValidatorTests(TestCase):
"""Tests settings validators."""
def test_validate_part_name_format(self):
"""Test error cases for validate_part_name_format."""
# No field
with self.assertRaises(ValidationError) as err:
common.setting.system.validate_part_name_format('abc{{}}')
self.assertEqual(
err.exception.messages[0],
'At least one field must be present inside a jinja template container i.e {{}}',
)
# Wrong field name
with self.assertRaises(ValidationError) as err:
common.setting.system.validate_part_name_format('{{part.wrong}}')
self.assertEqual(
err.exception.messages[0], 'wrong does not exist in Part Model'
)
# Broken templates
with self.assertRaises(ValidationError) as err:
common.setting.system.validate_part_name_format('{{')
self.assertEqual(err.exception.messages[0], "unexpected 'end of template'")
with self.assertRaises(ValidationError) as err:
common.setting.system.validate_part_name_format(None)
self.assertEqual(err.exception.messages[0], "Can't compile non template nodes")
# Correct template
self.assertTrue(
common.setting.system.validate_part_name_format('{{part.name}}'),
'test part',
)
def test_update_instance_name_no_multi(self):
"""Test valid cases for update_instance_name."""
self.assertIsNone(common.setting.system.update_instance_name('abc'))
def test_update_instance_url_no_multi(self):
"""Test update_instance_url."""
self.assertIsNone(common.setting.system.update_instance_url('abc.com'))

View File

@ -0,0 +1,57 @@
"""Types for settings."""
import sys
from typing import Any, Callable, TypedDict, Union
if sys.version_info >= (3, 11):
from typing import NotRequired # pragma: no cover
else:
class NotRequired: # pragma: no cover
"""NotRequired type helper is only supported with Python 3.11+."""
def __class_getitem__(cls, item):
"""Return the item."""
return item
class SettingsKeyType(TypedDict, total=False):
"""Type definitions for a SettingsKeyType.
Attributes:
name: Translatable string name of the setting (required)
description: Translatable string description of the setting (required)
units: Units of the particular setting (optional)
validator: Validation function/list of functions for the setting (optional, default: None, e.g: bool, int, str, MinValueValidator, ...)
default: Default value or function that returns default value (optional)
choices: Function that returns or value of list[tuple[str: key, str: display value]] (optional)
hidden: Hide this setting from settings page (optional)
before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional)
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
"""
name: str
description: str
units: str
validator: Union[Callable, list[Callable], tuple[Callable]]
default: Union[Callable, Any]
choices: Union[list[tuple[str, str]], Callable[[], list[tuple[str, str]]]]
hidden: bool
before_save: Callable[..., None]
after_save: Callable[..., None]
protected: bool
required: bool
model: str
class InvenTreeSettingsKeyType(SettingsKeyType):
"""InvenTreeSettingsKeyType has additional properties only global settings support.
Attributes:
requires_restart: If True, a server restart is required after changing the setting
"""
requires_restart: NotRequired[bool]

View File

@ -0,0 +1,339 @@
"""User settings definition."""
from django.core.validators import MinValueValidator
from django.utils.translation import gettext_lazy as _
from common.setting.type import InvenTreeSettingsKeyType
from plugin import registry
def label_printer_options():
"""Build a list of available label printer options."""
printers = []
label_printer_plugins = registry.with_mixin('labels')
if label_printer_plugins:
printers.extend([
(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins
])
return printers
USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'HOMEPAGE_HIDE_INACTIVE': {
'name': _('Hide inactive parts'),
'description': _('Hide inactive parts in results displayed on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PART_STARRED': {
'name': _('Show subscribed parts'),
'description': _('Show subscribed parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_CATEGORY_STARRED': {
'name': _('Show subscribed categories'),
'description': _('Show subscribed part categories on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PART_LATEST': {
'name': _('Show latest parts'),
'description': _('Show latest parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
'name': _('Show invalid BOMs'),
'description': _('Show BOMs that await validation on the homepage'),
'default': False,
'validator': bool,
},
'HOMEPAGE_STOCK_RECENT': {
'name': _('Show recent stock changes'),
'description': _('Show recently changed stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_LOW': {
'name': _('Show low stock'),
'description': _('Show low stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_SHOW_STOCK_DEPLETED': {
'name': _('Show depleted stock'),
'description': _('Show depleted stock items on the homepage'),
'default': False,
'validator': bool,
},
'HOMEPAGE_BUILD_STOCK_NEEDED': {
'name': _('Show needed stock'),
'description': _('Show stock items needed for builds on the homepage'),
'default': False,
'validator': bool,
},
'HOMEPAGE_STOCK_EXPIRED': {
'name': _('Show expired stock'),
'description': _('Show expired stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_STALE': {
'name': _('Show stale stock'),
'description': _('Show stale stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_BUILD_PENDING': {
'name': _('Show pending builds'),
'description': _('Show pending builds on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_BUILD_OVERDUE': {
'name': _('Show overdue builds'),
'description': _('Show overdue builds on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PO_OUTSTANDING': {
'name': _('Show outstanding POs'),
'description': _('Show outstanding POs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PO_OVERDUE': {
'name': _('Show overdue POs'),
'description': _('Show overdue POs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_SO_OUTSTANDING': {
'name': _('Show outstanding SOs'),
'description': _('Show outstanding SOs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_SO_OVERDUE': {
'name': _('Show overdue SOs'),
'description': _('Show overdue SOs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_SO_SHIPMENTS_PENDING': {
'name': _('Show pending SO shipments'),
'description': _('Show pending SO shipments on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_NEWS': {
'name': _('Show News'),
'description': _('Show news on the homepage'),
'default': False,
'validator': bool,
},
'LABEL_INLINE': {
'name': _('Inline label display'),
'description': _(
'Display PDF labels in the browser, instead of downloading as a file'
),
'default': True,
'validator': bool,
},
'LABEL_DEFAULT_PRINTER': {
'name': _('Default label printer'),
'description': _('Configure which label printer should be selected by default'),
'default': '',
'choices': label_printer_options,
},
'REPORT_INLINE': {
'name': _('Inline report display'),
'description': _(
'Display PDF reports in the browser, instead of downloading as a file'
),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_PARTS': {
'name': _('Search Parts'),
'description': _('Display parts in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS': {
'name': _('Search Supplier Parts'),
'description': _('Display supplier parts in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS': {
'name': _('Search Manufacturer Parts'),
'description': _('Display manufacturer parts in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_HIDE_INACTIVE_PARTS': {
'name': _('Hide Inactive Parts'),
'description': _('Excluded inactive parts from search preview window'),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_CATEGORIES': {
'name': _('Search Categories'),
'description': _('Display part categories in search preview window'),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_STOCK': {
'name': _('Search Stock'),
'description': _('Display stock items in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_HIDE_UNAVAILABLE_STOCK': {
'name': _('Hide Unavailable Stock Items'),
'description': _(
'Exclude stock items which are not available from the search preview window'
),
'validator': bool,
'default': False,
},
'SEARCH_PREVIEW_SHOW_LOCATIONS': {
'name': _('Search Locations'),
'description': _('Display stock locations in search preview window'),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_COMPANIES': {
'name': _('Search Companies'),
'description': _('Display companies in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_BUILD_ORDERS': {
'name': _('Search Build Orders'),
'description': _('Display build orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': {
'name': _('Search Purchase Orders'),
'description': _('Display purchase orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS': {
'name': _('Exclude Inactive Purchase Orders'),
'description': _('Exclude inactive purchase orders from search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_SALES_ORDERS': {
'name': _('Search Sales Orders'),
'description': _('Display sales orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS': {
'name': _('Exclude Inactive Sales Orders'),
'description': _('Exclude inactive sales orders from search preview window'),
'validator': bool,
'default': True,
},
'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': {
'name': _('Search Return Orders'),
'description': _('Display return orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': {
'name': _('Exclude Inactive Return Orders'),
'description': _('Exclude inactive return orders from search preview window'),
'validator': bool,
'default': True,
},
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _(
'Number of results to show in each section of the search preview window'
),
'default': 10,
'validator': [int, MinValueValidator(1)],
},
'SEARCH_REGEX': {
'name': _('Regex Search'),
'description': _('Enable regular expressions in search queries'),
'default': False,
'validator': bool,
},
'SEARCH_WHOLE': {
'name': _('Whole Word Search'),
'description': _('Search queries return results for whole word matches'),
'default': False,
'validator': bool,
},
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
'default': True,
'validator': bool,
},
'FORMS_CLOSE_USING_ESCAPE': {
'name': _('Escape Key Closes Forms'),
'description': _('Use the escape key to close modal forms'),
'default': False,
'validator': bool,
},
'STICKY_HEADER': {
'name': _('Fixed Navbar'),
'description': _('The navbar position is fixed to the top of the screen'),
'default': False,
'validator': bool,
},
'DATE_DISPLAY_FORMAT': {
'name': _('Date Format'),
'description': _('Preferred format for displaying dates'),
'default': 'YYYY-MM-DD',
'choices': [
('YYYY-MM-DD', '2022-02-22'),
('YYYY/MM/DD', '2022/22/22'),
('DD-MM-YYYY', '22-02-2022'),
('DD/MM/YYYY', '22/02/2022'),
('MM-DD-YYYY', '02-22-2022'),
('MM/DD/YYYY', '02/22/2022'),
('MMM DD YYYY', 'Feb 22 2022'),
],
},
'DISPLAY_SCHEDULE_TAB': {
'name': _('Part Scheduling'),
'description': _('Display part scheduling information'),
'default': True,
'validator': bool,
},
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Part Stocktake'),
'description': _(
'Display part stocktake information (if stocktake functionality is enabled)'
),
'default': True,
'validator': bool,
},
'TABLE_STRING_MAX_LENGTH': {
'name': _('Table String Length'),
'description': _('Maximum length limit for strings displayed in table views'),
'validator': [int, MinValueValidator(0)],
'default': 100,
},
'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),
'default': True,
'validator': bool,
},
'LAST_USED_PRINTING_MACHINES': {
'name': _('Last used printing machines'),
'description': _('Save the last used printing machines for a user'),
'default': '',
},
}

View File

@ -28,20 +28,6 @@ def cache(func):
return wrapper return wrapper
def barcode_plugins() -> list:
"""Return a list of plugin choices which can be used for barcode generation."""
try:
from plugin import registry
plugins = registry.with_mixin('barcode', active=True)
except Exception:
plugins = []
return [
(plug.slug, plug.human_name) for plug in plugins if plug.has_barcode_generation
]
def generate_barcode(model_instance: InvenTreeBarcodeMixin): def generate_barcode(model_instance: InvenTreeBarcodeMixin):
"""Generate a barcode for a given model instance.""" """Generate a barcode for a given model instance."""
from common.settings import get_global_setting from common.settings import get_global_setting

View File

@ -3,6 +3,7 @@
import logging import logging
from importlib import reload from importlib import reload
from pathlib import Path from pathlib import Path
from typing import Optional
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
@ -28,7 +29,14 @@ class AppMixin:
@classmethod @classmethod
def _activate_mixin( def _activate_mixin(
cls, registry, plugins, force_reload=False, full_reload: bool = False cls,
registry,
plugins,
force_reload=False,
full_reload: bool = False,
_internal: Optional[list] = None,
*args,
**kwargs,
): ):
"""Activate AppMixin plugins - add custom apps and reload. """Activate AppMixin plugins - add custom apps and reload.
@ -37,6 +45,7 @@ class AppMixin:
plugins (dict): List of IntegrationPlugins that should be installed plugins (dict): List of IntegrationPlugins that should be installed
force_reload (bool, optional): Only reload base apps. Defaults to False. force_reload (bool, optional): Only reload base apps. Defaults to False.
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
_internal (dict, optional): Internal use only (for testing). Defaults to None.
""" """
from common.settings import get_global_setting from common.settings import get_global_setting
@ -61,9 +70,11 @@ class AppMixin:
# first startup or force loading of base apps -> registry is prob false # first startup or force loading of base apps -> registry is prob false
if registry.apps_loading or force_reload: if registry.apps_loading or force_reload:
registry.apps_loading = False registry.apps_loading = False
registry._reload_apps(force_reload=True, full_reload=full_reload) registry._reload_apps(
force_reload=True, full_reload=full_reload, _internal=_internal
)
else: else:
registry._reload_apps(full_reload=full_reload) registry._reload_apps(full_reload=full_reload, _internal=_internal)
# rediscover models/ admin sites # rediscover models/ admin sites
cls._reregister_contrib_apps(cls, registry) cls._reregister_contrib_apps(cls, registry)

View File

@ -27,7 +27,13 @@ class UrlsMixin:
@classmethod @classmethod
def _activate_mixin( def _activate_mixin(
cls, registry, plugins, force_reload=False, full_reload: bool = False cls,
registry,
plugins,
force_reload=False,
full_reload: bool = False,
*args,
**kwargs,
): ):
"""Activate UrlsMixin plugins - add custom urls . """Activate UrlsMixin plugins - add custom urls .

View File

@ -15,7 +15,7 @@ from collections import OrderedDict
from importlib.machinery import SourceFileLoader from importlib.machinery import SourceFileLoader
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any, Union from typing import Any, Optional, Union
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
@ -212,17 +212,20 @@ class PluginsRegistry:
# endregion # endregion
# region loading / unloading # region loading / unloading
def _load_plugins(self, full_reload: bool = False): def _load_plugins(
self, full_reload: bool = False, _internal: Optional[list] = None
):
"""Load and activate all IntegrationPlugins. """Load and activate all IntegrationPlugins.
Args: Args:
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
_internal (list, optional): Internal apps to reload (used for testing). Defaults to None
""" """
logger.info('Loading plugins') logger.info('Loading plugins')
try: try:
self._init_plugins() self._init_plugins()
self._activate_plugins(full_reload=full_reload) self._activate_plugins(full_reload=full_reload, _internal=_internal)
except (OperationalError, ProgrammingError, IntegrityError): except (OperationalError, ProgrammingError, IntegrityError):
# Exception if the database has not been migrated yet, or is not ready # Exception if the database has not been migrated yet, or is not ready
pass pass
@ -262,6 +265,7 @@ class PluginsRegistry:
full_reload: bool = False, full_reload: bool = False,
force_reload: bool = False, force_reload: bool = False,
collect: bool = False, collect: bool = False,
_internal: Optional[list] = None,
): ):
"""Reload the plugin registry. """Reload the plugin registry.
@ -271,6 +275,7 @@ class PluginsRegistry:
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
force_reload (bool, optional): Also reload base apps. Defaults to False. force_reload (bool, optional): Also reload base apps. Defaults to False.
collect (bool, optional): Collect plugins before reloading. Defaults to False. collect (bool, optional): Collect plugins before reloading. Defaults to False.
_internal (list, optional): Internal apps to reload (used for testing). Defaults to None
""" """
# Do not reload when currently loading # Do not reload when currently loading
if self.is_loading: if self.is_loading:
@ -293,7 +298,7 @@ class PluginsRegistry:
self.plugins_loaded = False self.plugins_loaded = False
self._unload_plugins(force_reload=force_reload) self._unload_plugins(force_reload=force_reload)
self.plugins_loaded = True self.plugins_loaded = True
self._load_plugins(full_reload=full_reload) self._load_plugins(full_reload=full_reload, _internal=_internal)
self.update_plugin_hash() self.update_plugin_hash()
@ -601,7 +606,12 @@ class PluginsRegistry:
# Final list of mixins # Final list of mixins
return order return order
def _activate_plugins(self, force_reload=False, full_reload: bool = False): def _activate_plugins(
self,
force_reload=False,
full_reload: bool = False,
_internal: Optional[list] = None,
):
"""Run activation functions for all plugins. """Run activation functions for all plugins.
Args: Args:
@ -618,7 +628,11 @@ class PluginsRegistry:
for mixin in self.__get_mixin_order(): for mixin in self.__get_mixin_order():
if hasattr(mixin, '_activate_mixin'): if hasattr(mixin, '_activate_mixin'):
mixin._activate_mixin( mixin._activate_mixin(
self, plugins, force_reload=force_reload, full_reload=full_reload self,
plugins,
force_reload=force_reload,
full_reload=full_reload,
_internal=_internal,
) )
logger.debug('Done activating') logger.debug('Done activating')
@ -649,21 +663,31 @@ class PluginsRegistry:
except Exception as error: # pragma: no cover except Exception as error: # pragma: no cover
handle_error(error, do_raise=False) handle_error(error, do_raise=False)
def _reload_apps(self, force_reload: bool = False, full_reload: bool = False): def _reload_apps(
self,
force_reload: bool = False,
full_reload: bool = False,
_internal: Optional[list] = None,
):
"""Internal: reload apps using django internal functions. """Internal: reload apps using django internal functions.
Args: Args:
force_reload (bool, optional): Also reload base apps. Defaults to False. force_reload (bool, optional): Also reload base apps. Defaults to False.
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
_internal (list, optional): Internal use only (for testing). Defaults to None.
""" """
loadable_apps = settings.INSTALLED_APPS
if _internal:
loadable_apps += _internal
if force_reload: if force_reload:
# we can not use the built in functions as we need to brute force the registry # we can not use the built in functions as we need to brute force the registry
apps.app_configs = OrderedDict() apps.app_configs = OrderedDict()
apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
apps.clear_cache() apps.clear_cache()
self._try_reload(apps.populate, settings.INSTALLED_APPS) self._try_reload(apps.populate, loadable_apps)
else: else:
self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS) self._try_reload(apps.set_installed_apps, loadable_apps)
def _clean_installed_apps(self): def _clean_installed_apps(self):
for plugin in self.installed_apps: for plugin in self.installed_apps: