mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'matmair/issue2279' of https://github.com/matmair/InvenTree into matmair/issue2279
This commit is contained in:
commit
c6b88c2f24
19
.github/workflows/qc_checks.yaml
vendored
19
.github/workflows/qc_checks.yaml
vendored
@ -25,28 +25,9 @@ env:
|
|||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
check_version:
|
|
||||||
name: version number
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Check version number
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
run: |
|
|
||||||
python3 ci/check_version_number.py --branch ${{ github.base_ref }}
|
|
||||||
- name: Finish
|
|
||||||
if: always()
|
|
||||||
run: echo 'done'
|
|
||||||
|
|
||||||
pep_style:
|
pep_style:
|
||||||
name: PEP style (python)
|
name: PEP style (python)
|
||||||
needs: check_version
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
21
.github/workflows/version.yml
vendored
Normal file
21
.github/workflows/version.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Checks version number
|
||||||
|
name: version number
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
check_version:
|
||||||
|
name: version number
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Check version number
|
||||||
|
run: |
|
||||||
|
python3 ci/check_version_number.py --branch ${{ github.base_ref }}
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -77,4 +77,10 @@ dev/
|
|||||||
locale_stats.json
|
locale_stats.json
|
||||||
|
|
||||||
# node.js
|
# node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# maintenance locker
|
||||||
|
maintenance_mode_state.txt
|
||||||
|
|
||||||
|
# plugin dev directory
|
||||||
|
plugins/
|
||||||
|
@ -21,14 +21,14 @@ from .views import AjaxView
|
|||||||
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
|
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
|
||||||
from .status import is_worker_running
|
from .status import is_worker_running
|
||||||
|
|
||||||
from plugins import plugins as inventree_plugins
|
from plugin.plugins import load_action_plugins
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
|
|
||||||
logger.info("Loading action plugins...")
|
logger.info("Loading action plugins...")
|
||||||
action_plugins = inventree_plugins.load_action_plugins()
|
action_plugins = load_action_plugins()
|
||||||
|
|
||||||
|
|
||||||
class InfoView(AjaxView):
|
class InfoView(AjaxView):
|
||||||
|
@ -18,11 +18,32 @@ class InvenTreeConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
|
|
||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase():
|
||||||
|
|
||||||
|
self.remove_obsolete_tasks()
|
||||||
|
|
||||||
self.start_background_tasks()
|
self.start_background_tasks()
|
||||||
|
|
||||||
if not isInTestMode():
|
if not isInTestMode():
|
||||||
self.update_exchange_rates()
|
self.update_exchange_rates()
|
||||||
|
|
||||||
|
def remove_obsolete_tasks(self):
|
||||||
|
"""
|
||||||
|
Delete any obsolete scheduled tasks in the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
obsolete = [
|
||||||
|
'InvenTree.tasks.delete_expired_sessions',
|
||||||
|
'stock.tasks.delete_old_stock_items',
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django_q.models import Schedule
|
||||||
|
except (AppRegistryNotReady):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove any existing obsolete tasks
|
||||||
|
Schedule.objects.filter(func__in=obsolete).delete()
|
||||||
|
|
||||||
def start_background_tasks(self):
|
def start_background_tasks(self):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -57,25 +78,12 @@ class InvenTreeConfig(AppConfig):
|
|||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove expired sessions
|
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'InvenTree.tasks.delete_expired_sessions',
|
|
||||||
schedule_type=Schedule.DAILY,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete old error messages
|
# Delete old error messages
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'InvenTree.tasks.delete_old_error_logs',
|
'InvenTree.tasks.delete_old_error_logs',
|
||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete "old" stock items
|
|
||||||
InvenTree.tasks.schedule_task(
|
|
||||||
'stock.tasks.delete_old_stock_items',
|
|
||||||
schedule_type=Schedule.MINUTES,
|
|
||||||
minutes=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete old notification records
|
# Delete old notification records
|
||||||
InvenTree.tasks.schedule_task(
|
InvenTree.tasks.schedule_task(
|
||||||
'common.tasks.delete_old_notifications',
|
'common.tasks.delete_old_notifications',
|
||||||
|
@ -2,6 +2,7 @@ from common.settings import currency_code_default, currency_codes
|
|||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeExchange(SimpleExchangeBackend):
|
class InvenTreeExchange(SimpleExchangeBackend):
|
||||||
@ -32,3 +33,12 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
# catch connection errors
|
# catch connection errors
|
||||||
except (HTTPError, URLError):
|
except (HTTPError, URLError):
|
||||||
print('Encountered connection error while updating')
|
print('Encountered connection error while updating')
|
||||||
|
except OperationalError as e:
|
||||||
|
if 'SerializationFailure' in e.__cause__.__class__.__name__:
|
||||||
|
print('Serialization Failure while updating exchange rates')
|
||||||
|
# We are just going to swallow this exception because the
|
||||||
|
# exchange rates will be updated later by the scheduled task
|
||||||
|
else:
|
||||||
|
# Other operational errors probably are still show stoppers
|
||||||
|
# so reraise them so that the log contains the stacktrace
|
||||||
|
raise
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import importlib
|
|
||||||
import pkgutil
|
|
||||||
|
|
||||||
|
|
||||||
def iter_namespace(pkg):
|
|
||||||
|
|
||||||
return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
|
|
||||||
|
|
||||||
|
|
||||||
def get_modules(pkg):
|
|
||||||
# Return all modules in a given package
|
|
||||||
return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
|
|
||||||
|
|
||||||
|
|
||||||
def get_classes(module):
|
|
||||||
# Return all classes in a given module
|
|
||||||
return inspect.getmembers(module, inspect.isclass)
|
|
||||||
|
|
||||||
|
|
||||||
def get_plugins(pkg, baseclass):
|
|
||||||
"""
|
|
||||||
Return a list of all modules under a given package.
|
|
||||||
|
|
||||||
- Modules must be a subclass of the provided 'baseclass'
|
|
||||||
- Modules must have a non-empty PLUGIN_NAME parameter
|
|
||||||
"""
|
|
||||||
|
|
||||||
plugins = []
|
|
||||||
|
|
||||||
modules = get_modules(pkg)
|
|
||||||
|
|
||||||
# Iterate through each module in the package
|
|
||||||
for mod in modules:
|
|
||||||
# Iterate through each class in the module
|
|
||||||
for item in get_classes(mod):
|
|
||||||
plugin = item[1]
|
|
||||||
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
|
|
||||||
plugins.append(plugin)
|
|
||||||
|
|
||||||
return plugins
|
|
@ -86,6 +86,9 @@ if not os.path.exists(cfg_filename):
|
|||||||
with open(cfg_filename, 'r') as cfg:
|
with open(cfg_filename, 'r') as cfg:
|
||||||
CONFIG = yaml.safe_load(cfg)
|
CONFIG = yaml.safe_load(cfg)
|
||||||
|
|
||||||
|
# We will place any config files in the same directory as the config file
|
||||||
|
config_dir = os.path.dirname(cfg_filename)
|
||||||
|
|
||||||
# Default action is to run the system in Debug mode
|
# Default action is to run the system in Debug mode
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = _is_true(get_setting(
|
DEBUG = _is_true(get_setting(
|
||||||
@ -130,6 +133,11 @@ LOGGING = {
|
|||||||
'handlers': ['console'],
|
'handlers': ['console'],
|
||||||
'level': log_level,
|
'level': log_level,
|
||||||
},
|
},
|
||||||
|
'filters': {
|
||||||
|
'require_not_maintenance_mode_503': {
|
||||||
|
'()': 'maintenance_mode.logging.RequireNotMaintenanceMode503',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get a logger instance for this setup file
|
# Get a logger instance for this setup file
|
||||||
@ -201,6 +209,12 @@ if MEDIA_ROOT is None:
|
|||||||
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Options for django-maintenance-mode : https://pypi.org/project/django-maintenance-mode/
|
||||||
|
MAINTENANCE_MODE_STATE_FILE_PATH = os.path.join(
|
||||||
|
config_dir,
|
||||||
|
'maintenance_mode_state.txt',
|
||||||
|
)
|
||||||
|
|
||||||
# List of allowed hosts (default = allow all)
|
# List of allowed hosts (default = allow all)
|
||||||
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
||||||
|
|
||||||
@ -262,6 +276,9 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django.contrib.sites',
|
'django.contrib.sites',
|
||||||
|
|
||||||
|
# Maintenance
|
||||||
|
'maintenance_mode',
|
||||||
|
|
||||||
# InvenTree apps
|
# InvenTree apps
|
||||||
'build.apps.BuildConfig',
|
'build.apps.BuildConfig',
|
||||||
'common.apps.CommonConfig',
|
'common.apps.CommonConfig',
|
||||||
@ -272,6 +289,7 @@ INSTALLED_APPS = [
|
|||||||
'report.apps.ReportConfig',
|
'report.apps.ReportConfig',
|
||||||
'stock.apps.StockConfig',
|
'stock.apps.StockConfig',
|
||||||
'users.apps.UsersConfig',
|
'users.apps.UsersConfig',
|
||||||
|
'plugin.apps.PluginAppConfig',
|
||||||
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
||||||
|
|
||||||
# Third part add-ons
|
# Third part add-ons
|
||||||
@ -308,6 +326,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
|
|||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'InvenTree.middleware.AuthRequiredMiddleware',
|
'InvenTree.middleware.AuthRequiredMiddleware',
|
||||||
|
'maintenance_mode.middleware.MaintenanceModeMiddleware',
|
||||||
])
|
])
|
||||||
|
|
||||||
# Error reporting middleware
|
# Error reporting middleware
|
||||||
@ -335,7 +354,6 @@ TEMPLATES = [
|
|||||||
os.path.join(MEDIA_ROOT, 'report'),
|
os.path.join(MEDIA_ROOT, 'report'),
|
||||||
os.path.join(MEDIA_ROOT, 'label'),
|
os.path.join(MEDIA_ROOT, 'label'),
|
||||||
],
|
],
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'context_processors': [
|
'context_processors': [
|
||||||
'django.template.context_processors.debug',
|
'django.template.context_processors.debug',
|
||||||
@ -348,6 +366,13 @@ TEMPLATES = [
|
|||||||
'InvenTree.context.status_codes',
|
'InvenTree.context.status_codes',
|
||||||
'InvenTree.context.user_roles',
|
'InvenTree.context.user_roles',
|
||||||
],
|
],
|
||||||
|
'loaders': [(
|
||||||
|
'django.template.loaders.cached.Loader', [
|
||||||
|
'plugin.loader.PluginTemplateLoader',
|
||||||
|
'django.template.loaders.filesystem.Loader',
|
||||||
|
'django.template.loaders.app_directories.Loader',
|
||||||
|
])
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -558,7 +583,7 @@ _cache_port = _cache_config.get(
|
|||||||
if _cache_host:
|
if _cache_host:
|
||||||
# We are going to rely upon a possibly non-localhost for our cache,
|
# We are going to rely upon a possibly non-localhost for our cache,
|
||||||
# so don't wait too long for the cache as nothing in the cache should be
|
# so don't wait too long for the cache as nothing in the cache should be
|
||||||
# irreplacable. Django Q Cluster will just try again later.
|
# irreplacable.
|
||||||
_cache_options = {
|
_cache_options = {
|
||||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
"SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")),
|
"SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")),
|
||||||
@ -584,15 +609,9 @@ if _cache_host:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
CACHES = {
|
CACHES = {
|
||||||
# Connection configuration for Django Q Cluster
|
|
||||||
"worker": {
|
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
|
||||||
"LOCATION": f"redis://{_cache_host}:{_cache_port}/0",
|
|
||||||
"OPTIONS": _cache_options,
|
|
||||||
},
|
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
"LOCATION": f"redis://{_cache_host}:{_cache_port}/1",
|
"LOCATION": f"redis://{_cache_host}:{_cache_port}/0",
|
||||||
"OPTIONS": _cache_options,
|
"OPTIONS": _cache_options,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -876,3 +895,23 @@ MARKDOWNIFY_WHITELIST_ATTRS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
MARKDOWNIFY_BLEACH = False
|
MARKDOWNIFY_BLEACH = False
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
MAINTENANCE_MODE_RETRY_AFTER = 60
|
||||||
|
|
||||||
|
|
||||||
|
# Plugins
|
||||||
|
PLUGIN_DIRS = ['plugin.builtin', ]
|
||||||
|
|
||||||
|
if not TESTING:
|
||||||
|
# load local deploy directory in prod
|
||||||
|
PLUGIN_DIRS.append('plugins')
|
||||||
|
|
||||||
|
if DEBUG or TESTING:
|
||||||
|
# load samples in debug mode
|
||||||
|
PLUGIN_DIRS.append('plugin.samples')
|
||||||
|
|
||||||
|
# Plugin test settings
|
||||||
|
PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested?
|
||||||
|
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
|
||||||
|
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
|
||||||
|
@ -438,6 +438,12 @@
|
|||||||
width: 30%;
|
width: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* tracking table column size */
|
||||||
|
#track-table .table-condensed th {
|
||||||
|
inline-size: 30%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-heading .badge {
|
.panel-heading .badge {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
@ -231,25 +231,6 @@ def check_for_updates():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def delete_expired_sessions():
|
|
||||||
"""
|
|
||||||
Remove any expired user sessions from the database
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
from django.contrib.sessions.models import Session
|
|
||||||
|
|
||||||
# Delete any sessions that expired more than a day ago
|
|
||||||
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
|
|
||||||
|
|
||||||
if expired.count() > 0:
|
|
||||||
logger.info(f"Deleting {expired.count()} expired sessions.")
|
|
||||||
expired.delete()
|
|
||||||
|
|
||||||
except AppRegistryNotReady:
|
|
||||||
logger.info("Could not perform 'delete_expired_sessions' - App registry not ready")
|
|
||||||
|
|
||||||
|
|
||||||
def update_exchange_rates():
|
def update_exchange_rates():
|
||||||
"""
|
"""
|
||||||
Update currency exchange rates
|
Update currency exchange rates
|
||||||
|
@ -18,6 +18,7 @@ from part.urls import part_urls
|
|||||||
from stock.urls import stock_urls
|
from stock.urls import stock_urls
|
||||||
from build.urls import build_urls
|
from build.urls import build_urls
|
||||||
from order.urls import order_urls
|
from order.urls import order_urls
|
||||||
|
from plugin.urls import get_plugin_urls
|
||||||
|
|
||||||
from barcodes.api import barcode_api_urls
|
from barcodes.api import barcode_api_urls
|
||||||
from common.api import common_api_urls, settings_api_urls
|
from common.api import common_api_urls, settings_api_urls
|
||||||
@ -28,6 +29,7 @@ from build.api import build_api_urls
|
|||||||
from order.api import order_api_urls
|
from order.api import order_api_urls
|
||||||
from label.api import label_api_urls
|
from label.api import label_api_urls
|
||||||
from report.api import report_api_urls
|
from report.api import report_api_urls
|
||||||
|
from plugin.api import plugin_api_urls
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
@ -62,6 +64,7 @@ apipatterns = [
|
|||||||
url(r'^order/', include(order_api_urls)),
|
url(r'^order/', include(order_api_urls)),
|
||||||
url(r'^label/', include(label_api_urls)),
|
url(r'^label/', include(label_api_urls)),
|
||||||
url(r'^report/', include(report_api_urls)),
|
url(r'^report/', include(report_api_urls)),
|
||||||
|
url(r'^plugin/', include(plugin_api_urls)),
|
||||||
|
|
||||||
# User URLs
|
# User URLs
|
||||||
url(r'^user/', include(user_urls)),
|
url(r'^user/', include(user_urls)),
|
||||||
@ -123,6 +126,7 @@ translated_javascript_urls = [
|
|||||||
url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
|
url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
|
||||||
url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
|
url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
|
||||||
url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
|
url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
|
||||||
|
url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
|
||||||
url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
|
url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
|
||||||
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
|
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
|
||||||
]
|
]
|
||||||
@ -167,6 +171,9 @@ urlpatterns = [
|
|||||||
url(r'^api/', include(apipatterns)),
|
url(r'^api/', include(apipatterns)),
|
||||||
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
|
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
|
||||||
|
|
||||||
|
# plugin urls
|
||||||
|
get_plugin_urls(), # appends currently loaded plugin urls = None
|
||||||
|
|
||||||
url(r'^markdownx/', include('markdownx.urls')),
|
url(r'^markdownx/', include('markdownx.urls')),
|
||||||
|
|
||||||
# DB user sessions
|
# DB user sessions
|
||||||
|
@ -12,7 +12,8 @@ from rest_framework.views import APIView
|
|||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from stock.serializers import StockItemSerializer
|
from stock.serializers import StockItemSerializer
|
||||||
|
|
||||||
from barcodes.barcode import load_barcode_plugins, hash_barcode
|
from barcodes.barcode import hash_barcode
|
||||||
|
from plugin.plugins import load_barcode_plugins
|
||||||
|
|
||||||
|
|
||||||
class BarcodeScan(APIView):
|
class BarcodeScan(APIView):
|
||||||
@ -187,21 +188,21 @@ class BarcodeAssign(APIView):
|
|||||||
|
|
||||||
if plugin.getStockItem() is not None:
|
if plugin.getStockItem() is not None:
|
||||||
match_found = True
|
match_found = True
|
||||||
response['error'] = _('Barcode already matches StockItem object')
|
response['error'] = _('Barcode already matches Stock Item')
|
||||||
|
|
||||||
if plugin.getStockLocation() is not None:
|
if plugin.getStockLocation() is not None:
|
||||||
match_found = True
|
match_found = True
|
||||||
response['error'] = _('Barcode already matches StockLocation object')
|
response['error'] = _('Barcode already matches Stock Location')
|
||||||
|
|
||||||
if plugin.getPart() is not None:
|
if plugin.getPart() is not None:
|
||||||
match_found = True
|
match_found = True
|
||||||
response['error'] = _('Barcode already matches Part object')
|
response['error'] = _('Barcode already matches Part')
|
||||||
|
|
||||||
if not match_found:
|
if not match_found:
|
||||||
item = plugin.getStockItemByHash()
|
item = plugin.getStockItemByHash()
|
||||||
|
|
||||||
if item is not None:
|
if item is not None:
|
||||||
response['error'] = _('Barcode hash already matches StockItem object')
|
response['error'] = _('Barcode hash already matches Stock Item')
|
||||||
match_found = True
|
match_found = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -213,13 +214,13 @@ class BarcodeAssign(APIView):
|
|||||||
# Lookup stock item by hash
|
# Lookup stock item by hash
|
||||||
try:
|
try:
|
||||||
item = StockItem.objects.get(uid=hash)
|
item = StockItem.objects.get(uid=hash)
|
||||||
response['error'] = _('Barcode hash already matches StockItem object')
|
response['error'] = _('Barcode hash already matches Stock Item')
|
||||||
match_found = True
|
match_found = True
|
||||||
except StockItem.DoesNotExist:
|
except StockItem.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not match_found:
|
if not match_found:
|
||||||
response['success'] = _('Barcode associated with StockItem')
|
response['success'] = _('Barcode associated with Stock Item')
|
||||||
|
|
||||||
# Save the barcode hash
|
# Save the barcode hash
|
||||||
item.uid = response['hash']
|
item.uid = response['hash']
|
||||||
|
@ -4,8 +4,6 @@ import string
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from InvenTree import plugins as InvenTreePlugins
|
|
||||||
from barcodes import plugins as BarcodePlugins
|
|
||||||
|
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from stock.serializers import StockItemSerializer, LocationSerializer
|
from stock.serializers import StockItemSerializer, LocationSerializer
|
||||||
@ -139,24 +137,3 @@ class BarcodePlugin:
|
|||||||
Default implementation returns False
|
Default implementation returns False
|
||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def load_barcode_plugins(debug=False):
|
|
||||||
"""
|
|
||||||
Function to load all barcode plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug("Loading barcode plugins")
|
|
||||||
|
|
||||||
plugins = InvenTreePlugins.get_plugins(BarcodePlugins, BarcodePlugin)
|
|
||||||
|
|
||||||
if debug:
|
|
||||||
if len(plugins) > 0:
|
|
||||||
logger.info(f"Discovered {len(plugins)} barcode plugins")
|
|
||||||
|
|
||||||
for p in plugins:
|
|
||||||
logger.debug(" - {p}".format(p=p.PLUGIN_NAME))
|
|
||||||
else:
|
|
||||||
logger.debug("No barcode plugins found")
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
|
@ -19,7 +19,8 @@ def add_default_reference(apps, schema_editor):
|
|||||||
build.save()
|
build.save()
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
|
if count > 0:
|
||||||
|
print(f"\nUpdated build reference for {count} existing BuildOrder objects")
|
||||||
|
|
||||||
|
|
||||||
def reverse_default_reference(apps, schema_editor):
|
def reverse_default_reference(apps, schema_editor):
|
||||||
|
@ -10,7 +10,6 @@ from InvenTree import status_codes as status
|
|||||||
from build.models import Build, BuildItem, get_next_build_number
|
from build.models import Build, BuildItem, get_next_build_number
|
||||||
from part.models import Part, BomItem
|
from part.models import Part, BomItem
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from stock.tasks import delete_old_stock_items
|
|
||||||
|
|
||||||
|
|
||||||
class BuildTest(TestCase):
|
class BuildTest(TestCase):
|
||||||
@ -354,11 +353,6 @@ class BuildTest(TestCase):
|
|||||||
# the original BuildItem objects should have been deleted!
|
# the original BuildItem objects should have been deleted!
|
||||||
self.assertEqual(BuildItem.objects.count(), 0)
|
self.assertEqual(BuildItem.objects.count(), 0)
|
||||||
|
|
||||||
self.assertEqual(StockItem.objects.count(), 8)
|
|
||||||
|
|
||||||
# Clean up old stock items
|
|
||||||
delete_old_stock_items()
|
|
||||||
|
|
||||||
# New stock items should have been created!
|
# New stock items should have been created!
|
||||||
self.assertEqual(StockItem.objects.count(), 7)
|
self.assertEqual(StockItem.objects.count(), 7)
|
||||||
|
|
||||||
|
@ -962,6 +962,34 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'default': '',
|
'default': '',
|
||||||
'choices': settings_group_options
|
'choices': settings_group_options
|
||||||
},
|
},
|
||||||
|
'ENABLE_PLUGINS_URL': {
|
||||||
|
'name': _('Enable URL integration'),
|
||||||
|
'description': _('Enable plugins to add URL routes'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
'requires_restart': True,
|
||||||
|
},
|
||||||
|
'ENABLE_PLUGINS_NAVIGATION': {
|
||||||
|
'name': _('Enable navigation integration'),
|
||||||
|
'description': _('Enable plugins to integrate into navigation'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
'requires_restart': True,
|
||||||
|
},
|
||||||
|
'ENABLE_PLUGINS_GLOBALSETTING': {
|
||||||
|
'name': _('Enable global setting integration'),
|
||||||
|
'description': _('Enable plugins to integrate into inventree global settings'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
'requires_restart': True,
|
||||||
|
},
|
||||||
|
'ENABLE_PLUGINS_APP': {
|
||||||
|
'name': _('Enable app integration'),
|
||||||
|
'description': _('Enable plugins to add apps'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
'requires_restart': True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
fields:
|
fields:
|
||||||
name: Zerg Corp
|
name: Zerg Corp
|
||||||
description: We eat the competition
|
description: We eat the competition
|
||||||
|
is_customer: False
|
||||||
|
|
||||||
- model: company.company
|
- model: company.company
|
||||||
pk: 4
|
pk: 4
|
||||||
|
@ -277,13 +277,31 @@ class POLineItemFilter(rest_filters.FilterSet):
|
|||||||
'part'
|
'part'
|
||||||
]
|
]
|
||||||
|
|
||||||
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
|
||||||
|
|
||||||
def filter_completed(self, queryset, name, value):
|
def filter_pending(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter by "pending" status (order status = pending)
|
||||||
"""
|
"""
|
||||||
Filter by lines which are "completed" (or "not" completed)
|
|
||||||
|
|
||||||
A line is completed when received >= quantity
|
value = str2bool(value)
|
||||||
|
|
||||||
|
if value:
|
||||||
|
queryset = queryset.filter(order__status__in=PurchaseOrderStatus.OPEN)
|
||||||
|
else:
|
||||||
|
queryset = queryset.exclude(order__status__in=PurchaseOrderStatus.OPEN)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status')
|
||||||
|
|
||||||
|
received = rest_filters.BooleanFilter(label='received', method='filter_received')
|
||||||
|
|
||||||
|
def filter_received(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter by lines which are "received" (or "not" received)
|
||||||
|
|
||||||
|
A line is considered "received" when received >= quantity
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
@ -293,7 +311,8 @@ class POLineItemFilter(rest_filters.FilterSet):
|
|||||||
if value:
|
if value:
|
||||||
queryset = queryset.filter(q)
|
queryset = queryset.filter(q)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.exclude(q)
|
# Only count "pending" orders
|
||||||
|
queryset = queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
7
InvenTree/plugin/__init__.py
Normal file
7
InvenTree/plugin/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from .registry import plugins as plugin_reg
|
||||||
|
from .integration import IntegrationPluginBase
|
||||||
|
from .action import ActionPlugin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
|
||||||
|
]
|
@ -1,8 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Class for ActionPlugin"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import plugins.plugin as plugin
|
import plugin.plugin as plugin
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
@ -42,7 +43,6 @@ class ActionPlugin(plugin.InvenTreePlugin):
|
|||||||
"""
|
"""
|
||||||
Override this method to perform the action!
|
Override this method to perform the action!
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self):
|
||||||
"""
|
"""
|
||||||
@ -68,25 +68,3 @@ class ActionPlugin(plugin.InvenTreePlugin):
|
|||||||
"result": self.get_result(),
|
"result": self.get_result(),
|
||||||
"info": self.get_info(),
|
"info": self.get_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SimpleActionPlugin(ActionPlugin):
|
|
||||||
"""
|
|
||||||
An EXTREMELY simple action plugin which demonstrates
|
|
||||||
the capability of the ActionPlugin class
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLUGIN_NAME = "SimpleActionPlugin"
|
|
||||||
ACTION_NAME = "simple"
|
|
||||||
|
|
||||||
def perform_action(self):
|
|
||||||
print("Action plugin in action!")
|
|
||||||
|
|
||||||
def get_info(self):
|
|
||||||
return {
|
|
||||||
"user": self.user.username,
|
|
||||||
"hello": "world",
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_result(self):
|
|
||||||
return True
|
|
46
InvenTree/plugin/admin.py
Normal file
46
InvenTree/plugin/admin.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
import plugin.models as models
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_update(queryset, new_status: bool):
|
||||||
|
"""general function for bulk changing plugins"""
|
||||||
|
apps_changed = False
|
||||||
|
|
||||||
|
# run through all plugins in the queryset as the save method needs to be overridden
|
||||||
|
for plugin in queryset:
|
||||||
|
if plugin.active is not new_status:
|
||||||
|
plugin.active = new_status
|
||||||
|
plugin.save(no_reload=True)
|
||||||
|
apps_changed = True
|
||||||
|
|
||||||
|
# reload plugins if they changed
|
||||||
|
if apps_changed:
|
||||||
|
plugin_reg.reload_plugins()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description='Activate plugin(s)')
|
||||||
|
def plugin_activate(modeladmin, request, queryset):
|
||||||
|
"""activate a set of plugins"""
|
||||||
|
plugin_update(queryset, True)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description='Deactivate plugin(s)')
|
||||||
|
def plugin_deactivate(modeladmin, request, queryset):
|
||||||
|
"""deactivate a set of plugins"""
|
||||||
|
plugin_update(queryset, False)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginConfigAdmin(admin.ModelAdmin):
|
||||||
|
"""Custom admin with restricted id fields"""
|
||||||
|
readonly_fields = ["key", "name", ]
|
||||||
|
list_display = ['active', '__str__', 'key', 'name', ]
|
||||||
|
list_filter = ['active']
|
||||||
|
actions = [plugin_activate, plugin_deactivate, ]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.PluginConfig, PluginConfigAdmin)
|
89
InvenTree/plugin/api.py
Normal file
89
InvenTree/plugin/api.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
JSON API for the plugin app
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
|
from rest_framework import generics
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from plugin.models import PluginConfig
|
||||||
|
import plugin.serializers as PluginSerializers
|
||||||
|
|
||||||
|
|
||||||
|
class PluginList(generics.ListAPIView):
|
||||||
|
""" API endpoint for list of PluginConfig objects
|
||||||
|
|
||||||
|
- GET: Return a list of all PluginConfig objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||||
|
queryset = PluginConfig.objects.all()
|
||||||
|
|
||||||
|
ordering_fields = [
|
||||||
|
'key',
|
||||||
|
'name',
|
||||||
|
'active',
|
||||||
|
]
|
||||||
|
|
||||||
|
ordering = [
|
||||||
|
'key',
|
||||||
|
]
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'key',
|
||||||
|
'name',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
""" API detail endpoint for PluginConfig object
|
||||||
|
|
||||||
|
get:
|
||||||
|
Return a single PluginConfig object
|
||||||
|
|
||||||
|
post:
|
||||||
|
Update a PluginConfig
|
||||||
|
|
||||||
|
delete:
|
||||||
|
Remove a PluginConfig
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PluginConfig.objects.all()
|
||||||
|
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInstall(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
Endpoint for installing a new plugin
|
||||||
|
"""
|
||||||
|
queryset = PluginConfig.objects.none()
|
||||||
|
serializer_class = PluginSerializers.PluginConfigInstallSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
result = self.perform_create(serializer)
|
||||||
|
result['input'] = serializer.data
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(result, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
return serializer.save()
|
||||||
|
|
||||||
|
|
||||||
|
plugin_api_urls = [
|
||||||
|
# Detail views for a single PluginConfig item
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
url(r'^install/', PluginInstall.as_view(), name='api-plugin-install'),
|
||||||
|
|
||||||
|
# Anything else
|
||||||
|
url(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
|
||||||
|
]
|
21
InvenTree/plugin/apps.py
Normal file
21
InvenTree/plugin/apps.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from maintenance_mode.core import set_maintenance_mode
|
||||||
|
|
||||||
|
from plugin.registry import plugins
|
||||||
|
|
||||||
|
|
||||||
|
class PluginAppConfig(AppConfig):
|
||||||
|
name = 'plugin'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
if not plugins.is_loading:
|
||||||
|
# this is the first startup
|
||||||
|
plugins.collect_plugins()
|
||||||
|
plugins.load_plugins()
|
||||||
|
|
||||||
|
# drop out of maintenance
|
||||||
|
# makes sure we did not have an error in reloading and maintenance is still active
|
||||||
|
set_maintenance_mode(False)
|
0
InvenTree/plugin/builtin/action/__init__.py
Normal file
0
InvenTree/plugin/builtin/action/__init__.py
Normal file
25
InvenTree/plugin/builtin/action/simpleactionplugin.py
Normal file
25
InvenTree/plugin/builtin/action/simpleactionplugin.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""sample implementation for ActionPlugin"""
|
||||||
|
from plugin.action import ActionPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleActionPlugin(ActionPlugin):
|
||||||
|
"""
|
||||||
|
An EXTREMELY simple action plugin which demonstrates
|
||||||
|
the capability of the ActionPlugin class
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLUGIN_NAME = "SimpleActionPlugin"
|
||||||
|
ACTION_NAME = "simple"
|
||||||
|
|
||||||
|
def perform_action(self):
|
||||||
|
print("Action plugin in action!")
|
||||||
|
|
||||||
|
def get_info(self):
|
||||||
|
return {
|
||||||
|
"user": self.user.username,
|
||||||
|
"hello": "world",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_result(self):
|
||||||
|
return True
|
40
InvenTree/plugin/builtin/action/test_samples_action.py
Normal file
40
InvenTree/plugin/builtin/action/test_samples_action.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
""" Unit tests for action plugins """
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from plugin.builtin.action.simpleactionplugin import SimpleActionPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleActionPluginTests(TestCase):
|
||||||
|
""" Tests for SampleIntegrationPlugin """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Create a user for auth
|
||||||
|
user = get_user_model()
|
||||||
|
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
|
self.client.login(username='testuser', password='password')
|
||||||
|
self.plugin = SimpleActionPlugin(user=self.test_user)
|
||||||
|
|
||||||
|
def test_name(self):
|
||||||
|
"""check plugn names """
|
||||||
|
self.assertEqual(self.plugin.plugin_name(), "SimpleActionPlugin")
|
||||||
|
self.assertEqual(self.plugin.action_name(), "simple")
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
"""check if functions work """
|
||||||
|
# test functions
|
||||||
|
response = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar", }})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
str(response.content, encoding='utf8'),
|
||||||
|
{
|
||||||
|
"action": 'simple',
|
||||||
|
"result": True,
|
||||||
|
"info": {
|
||||||
|
"user": "testuser",
|
||||||
|
"hello": "world",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
0
InvenTree/plugin/builtin/integration/__init__.py
Normal file
0
InvenTree/plugin/builtin/integration/__init__.py
Normal file
169
InvenTree/plugin/builtin/integration/mixins.py
Normal file
169
InvenTree/plugin/builtin/integration/mixins.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""default mixins for IntegrationMixins"""
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettingsMixin:
|
||||||
|
"""Mixin that enables global settings for the plugin"""
|
||||||
|
class MixinMeta:
|
||||||
|
"""meta options for this mixin"""
|
||||||
|
MIXIN_NAME = 'Global settings'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin('globalsettings', 'has_globalsettings', __class__)
|
||||||
|
self.globalsettings = self.setup_globalsettings()
|
||||||
|
|
||||||
|
def setup_globalsettings(self):
|
||||||
|
"""
|
||||||
|
setup global settings for this plugin
|
||||||
|
"""
|
||||||
|
return getattr(self, 'GLOBALSETTINGS', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_globalsettings(self):
|
||||||
|
"""
|
||||||
|
does this plugin use custom global settings
|
||||||
|
"""
|
||||||
|
return bool(self.globalsettings)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def globalsettingspatterns(self):
|
||||||
|
"""
|
||||||
|
get patterns for InvenTreeSetting defintion
|
||||||
|
"""
|
||||||
|
if self.has_globalsettings:
|
||||||
|
return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _globalsetting_name(self, key):
|
||||||
|
"""get global name of setting"""
|
||||||
|
return f'PLUGIN_{self.slug.upper()}_{key}'
|
||||||
|
|
||||||
|
def get_globalsetting(self, key):
|
||||||
|
"""
|
||||||
|
get plugin global setting by key
|
||||||
|
"""
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
return InvenTreeSetting.get_setting(self._globalsetting_name(key))
|
||||||
|
|
||||||
|
def set_globalsetting(self, key, value, user):
|
||||||
|
"""
|
||||||
|
set plugin global setting by key
|
||||||
|
"""
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
|
||||||
|
|
||||||
|
|
||||||
|
class UrlsMixin:
|
||||||
|
"""Mixin that enables urls for the plugin"""
|
||||||
|
class MixinMeta:
|
||||||
|
"""meta options for this mixin"""
|
||||||
|
MIXIN_NAME = 'URLs'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin('urls', 'has_urls', __class__)
|
||||||
|
self.urls = self.setup_urls()
|
||||||
|
|
||||||
|
def setup_urls(self):
|
||||||
|
"""
|
||||||
|
setup url endpoints for this plugin
|
||||||
|
"""
|
||||||
|
return getattr(self, 'URLS', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self):
|
||||||
|
"""
|
||||||
|
returns base url for this plugin
|
||||||
|
"""
|
||||||
|
return f'{PLUGIN_BASE}/{self.slug}/'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_name(self):
|
||||||
|
"""
|
||||||
|
returns the internal url pattern name
|
||||||
|
"""
|
||||||
|
return f'plugin:{self.slug}:'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def urlpatterns(self):
|
||||||
|
"""
|
||||||
|
returns the urlpatterns for this plugin
|
||||||
|
"""
|
||||||
|
if self.has_urls:
|
||||||
|
return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_urls(self):
|
||||||
|
"""
|
||||||
|
does this plugin use custom urls
|
||||||
|
"""
|
||||||
|
return bool(self.urls)
|
||||||
|
|
||||||
|
|
||||||
|
class NavigationMixin:
|
||||||
|
"""Mixin that enables adding navigation links with the plugin"""
|
||||||
|
NAVIGATION_TAB_NAME = None
|
||||||
|
NAVIGATION_TAB_ICON = "fas fa-question"
|
||||||
|
|
||||||
|
class MixinMeta:
|
||||||
|
"""meta options for this mixin"""
|
||||||
|
MIXIN_NAME = 'Navigation Links'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin('navigation', 'has_naviation', __class__)
|
||||||
|
self.navigation = self.setup_navigation()
|
||||||
|
|
||||||
|
def setup_navigation(self):
|
||||||
|
"""
|
||||||
|
setup navigation links for this plugin
|
||||||
|
"""
|
||||||
|
nav_links = getattr(self, 'NAVIGATION', None)
|
||||||
|
if nav_links:
|
||||||
|
# check if needed values are configured
|
||||||
|
for link in nav_links:
|
||||||
|
if False in [a in link for a in ('link', 'name', )]:
|
||||||
|
raise NotImplementedError('Wrong Link definition', link)
|
||||||
|
return nav_links
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_naviation(self):
|
||||||
|
"""
|
||||||
|
does this plugin define navigation elements
|
||||||
|
"""
|
||||||
|
return bool(self.navigation)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def navigation_name(self):
|
||||||
|
"""name for navigation tab"""
|
||||||
|
name = getattr(self, 'NAVIGATION_TAB_NAME', None)
|
||||||
|
if not name:
|
||||||
|
name = self.human_name
|
||||||
|
return name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def navigation_icon(self):
|
||||||
|
"""icon for navigation tab"""
|
||||||
|
return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
|
||||||
|
|
||||||
|
|
||||||
|
class AppMixin:
|
||||||
|
"""Mixin that enables full django app functions for a plugin"""
|
||||||
|
class MixinMeta:
|
||||||
|
"""meta options for this mixin"""
|
||||||
|
MIXIN_NAME = 'App registration'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin('app', 'has_app', __class__)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_app(self):
|
||||||
|
"""
|
||||||
|
this plugin is always an app with this plugin
|
||||||
|
"""
|
||||||
|
return True
|
102
InvenTree/plugin/helpers.py
Normal file
102
InvenTree/plugin/helpers.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""Helpers for plugin app"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import pathlib
|
||||||
|
import sysconfig
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
# region logging / errors
|
||||||
|
def log_plugin_error(error, reference: str = 'general'):
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
# make sure the registry is set up
|
||||||
|
if reference not in plugin_reg.errors:
|
||||||
|
plugin_reg.errors[reference] = []
|
||||||
|
|
||||||
|
# add error to stack
|
||||||
|
plugin_reg.errors[reference].append(error)
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationPluginError(Exception):
|
||||||
|
def __init__(self, path, message):
|
||||||
|
self.path = path
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_name: str = ''):
|
||||||
|
package_path = traceback.extract_tb(error.__traceback__)[-1].filename
|
||||||
|
install_path = sysconfig.get_paths()["purelib"]
|
||||||
|
try:
|
||||||
|
package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
|
||||||
|
except ValueError:
|
||||||
|
# is file - loaded -> form a name for that
|
||||||
|
path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
|
||||||
|
path_parts = [*path_obj.parts]
|
||||||
|
path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix
|
||||||
|
|
||||||
|
# remove path preixes
|
||||||
|
if path_parts[0] == 'plugin':
|
||||||
|
path_parts.remove('plugin')
|
||||||
|
path_parts.pop(0)
|
||||||
|
else:
|
||||||
|
path_parts.remove('plugins')
|
||||||
|
|
||||||
|
package_name = '.'.join(path_parts)
|
||||||
|
|
||||||
|
if do_log:
|
||||||
|
log_kwargs = {}
|
||||||
|
if log_name:
|
||||||
|
log_kwargs['reference'] = log_name
|
||||||
|
log_plugin_error({package_name: str(error)}, **log_kwargs)
|
||||||
|
|
||||||
|
if do_raise:
|
||||||
|
raise IntegrationPluginError(package_name, str(error))
|
||||||
|
|
||||||
|
return package_name, str(error)
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
# region git-helpers
|
||||||
|
def get_git_log(path):
|
||||||
|
"""get dict with info of the last commit to file named in path"""
|
||||||
|
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
|
||||||
|
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
|
||||||
|
try:
|
||||||
|
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
|
||||||
|
if output:
|
||||||
|
output = output.split('\n')
|
||||||
|
else:
|
||||||
|
output = 7 * ['']
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
output = 7 * ['']
|
||||||
|
return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
|
||||||
|
|
||||||
|
|
||||||
|
class GitStatus:
|
||||||
|
"""class for resolving git gpg singing state"""
|
||||||
|
class Definition:
|
||||||
|
"""definition of a git gpg sing state"""
|
||||||
|
key: str = 'N'
|
||||||
|
status: int = 2
|
||||||
|
msg: str = ''
|
||||||
|
|
||||||
|
def __init__(self, key: str = 'N', status: int = 2, msg: str = '') -> None:
|
||||||
|
self.key = key
|
||||||
|
self.status = status
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
N = Definition(key='N', status=2, msg='no signature',)
|
||||||
|
G = Definition(key='G', status=0, msg='valid signature',)
|
||||||
|
B = Definition(key='B', status=2, msg='bad signature',)
|
||||||
|
U = Definition(key='U', status=1, msg='good signature, unknown validity',)
|
||||||
|
X = Definition(key='X', status=1, msg='good signature, expired',)
|
||||||
|
Y = Definition(key='Y', status=1, msg='good signature, expired key',)
|
||||||
|
R = Definition(key='R', status=2, msg='good signature, revoked key',)
|
||||||
|
E = Definition(key='E', status=1, msg='cannot be checked',)
|
||||||
|
# endregion
|
204
InvenTree/plugin/integration.py
Normal file
204
InvenTree/plugin/integration.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""class for IntegrationPluginBase and Mixins for it"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import inspect
|
||||||
|
from datetime import datetime
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from django.urls.base import reverse
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
import plugin.plugin as plugin
|
||||||
|
from plugin.helpers import get_git_log, GitStatus
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
|
|
||||||
|
class MixinBase:
|
||||||
|
"""general base for mixins"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._mixinreg = {}
|
||||||
|
self._mixins = {}
|
||||||
|
|
||||||
|
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
||||||
|
"""add a mixin to the plugins registry"""
|
||||||
|
self._mixins[key] = fnc_enabled
|
||||||
|
self.setup_mixin(key, cls=cls)
|
||||||
|
|
||||||
|
def setup_mixin(self, key, cls=None):
|
||||||
|
"""define mixin details for the current mixin -> provides meta details for all active mixins"""
|
||||||
|
# get human name
|
||||||
|
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
|
||||||
|
|
||||||
|
# register
|
||||||
|
self._mixinreg[key] = {
|
||||||
|
'key': key,
|
||||||
|
'human_name': human_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registered_mixins(self, with_base: bool = False):
|
||||||
|
"""get all registered mixins for the plugin"""
|
||||||
|
mixins = getattr(self, '_mixinreg', None)
|
||||||
|
if mixins:
|
||||||
|
# filter out base
|
||||||
|
if not with_base and 'base' in mixins:
|
||||||
|
del mixins['base']
|
||||||
|
# only return dict
|
||||||
|
mixins = [a for a in mixins.values()]
|
||||||
|
return mixins
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
|
||||||
|
"""
|
||||||
|
The IntegrationPluginBase class is used to integrate with 3rd party software
|
||||||
|
"""
|
||||||
|
PLUGIN_SLUG = None
|
||||||
|
PLUGIN_TITLE = None
|
||||||
|
|
||||||
|
AUTHOR = None
|
||||||
|
DESCRIPTION = None
|
||||||
|
PUBLISH_DATE = None
|
||||||
|
VERSION = None
|
||||||
|
WEBSITE = None
|
||||||
|
LICENSE = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin('base')
|
||||||
|
self.def_path = inspect.getfile(self.__class__)
|
||||||
|
self.path = os.path.dirname(self.def_path)
|
||||||
|
|
||||||
|
self.set_package()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _is_package(self):
|
||||||
|
return getattr(self, 'is_package', False)
|
||||||
|
|
||||||
|
# region properties
|
||||||
|
@property
|
||||||
|
def slug(self):
|
||||||
|
"""slug for the plugin"""
|
||||||
|
slug = getattr(self, 'PLUGIN_SLUG', None)
|
||||||
|
if not slug:
|
||||||
|
slug = self.plugin_name()
|
||||||
|
return slugify(slug)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def human_name(self):
|
||||||
|
"""human readable name for labels etc."""
|
||||||
|
human_name = getattr(self, 'PLUGIN_TITLE', None)
|
||||||
|
if not human_name:
|
||||||
|
human_name = self.plugin_name()
|
||||||
|
return human_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self):
|
||||||
|
"""description of plugin"""
|
||||||
|
description = getattr(self, 'DESCRIPTION', None)
|
||||||
|
if not description:
|
||||||
|
description = self.plugin_name()
|
||||||
|
return description
|
||||||
|
|
||||||
|
@property
|
||||||
|
def author(self):
|
||||||
|
"""returns author of plugin - either from plugin settings or git"""
|
||||||
|
author = getattr(self, 'AUTHOR', None)
|
||||||
|
if not author:
|
||||||
|
author = self.package.get('author')
|
||||||
|
if not author:
|
||||||
|
author = _('No author found')
|
||||||
|
return author
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pub_date(self):
|
||||||
|
"""returns publishing date of plugin - either from plugin settings or git"""
|
||||||
|
pub_date = getattr(self, 'PUBLISH_DATE', None)
|
||||||
|
if not pub_date:
|
||||||
|
pub_date = self.package.get('date')
|
||||||
|
else:
|
||||||
|
pub_date = datetime.fromisoformat(str(pub_date))
|
||||||
|
if not pub_date:
|
||||||
|
pub_date = _('No date found')
|
||||||
|
return pub_date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self):
|
||||||
|
"""returns version of plugin"""
|
||||||
|
version = getattr(self, 'VERSION', None)
|
||||||
|
return version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def website(self):
|
||||||
|
"""returns website of plugin"""
|
||||||
|
website = getattr(self, 'WEBSITE', None)
|
||||||
|
return website
|
||||||
|
|
||||||
|
@property
|
||||||
|
def license(self):
|
||||||
|
"""returns license of plugin"""
|
||||||
|
license = getattr(self, 'LICENSE', None)
|
||||||
|
return license
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
@property
|
||||||
|
def package_path(self):
|
||||||
|
"""returns the path to the plugin"""
|
||||||
|
if self._is_package:
|
||||||
|
return self.__module__
|
||||||
|
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings_url(self):
|
||||||
|
"""returns url to the settings panel"""
|
||||||
|
return f'{reverse("settings")}#select-plugin-{self.slug}'
|
||||||
|
|
||||||
|
# region mixins
|
||||||
|
def mixin(self, key):
|
||||||
|
"""check if mixin is registered"""
|
||||||
|
return key in self._mixins
|
||||||
|
|
||||||
|
def mixin_enabled(self, key):
|
||||||
|
"""check if mixin is enabled and ready"""
|
||||||
|
if self.mixin(key):
|
||||||
|
fnc_name = self._mixins.get(key)
|
||||||
|
return getattr(self, fnc_name, True)
|
||||||
|
return False
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# region package info
|
||||||
|
def get_package_commit(self):
|
||||||
|
"""get last git commit for plugin"""
|
||||||
|
return get_git_log(self.def_path)
|
||||||
|
|
||||||
|
def get_package_metadata(self):
|
||||||
|
"""get package metadata for plugin"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def set_package(self):
|
||||||
|
"""add packaging info of the plugins into plugins context"""
|
||||||
|
package = self.get_package_metadata() if self._is_package else self.get_package_commit()
|
||||||
|
|
||||||
|
# process date
|
||||||
|
if package.get('date'):
|
||||||
|
package['date'] = datetime.fromisoformat(package.get('date'))
|
||||||
|
|
||||||
|
# process sign state
|
||||||
|
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
||||||
|
if sign_state.status == 0:
|
||||||
|
self.sign_color = 'success'
|
||||||
|
elif sign_state.status == 1:
|
||||||
|
self.sign_color = 'warning'
|
||||||
|
else:
|
||||||
|
self.sign_color = 'danger'
|
||||||
|
|
||||||
|
# set variables
|
||||||
|
self.package = package
|
||||||
|
self.sign_state = sign_state
|
||||||
|
# endregion
|
19
InvenTree/plugin/loader.py
Normal file
19
InvenTree/plugin/loader.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
load templates for loaded plugins
|
||||||
|
"""
|
||||||
|
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTemplateLoader(FilesystemLoader):
|
||||||
|
|
||||||
|
def get_dirs(self):
|
||||||
|
dirname = 'templates'
|
||||||
|
template_dirs = []
|
||||||
|
for plugin in plugin_reg.plugins.values():
|
||||||
|
new_path = Path(plugin.path) / dirname
|
||||||
|
if Path(new_path).is_dir():
|
||||||
|
template_dirs.append(new_path)
|
||||||
|
return tuple(template_dirs)
|
23
InvenTree/plugin/migrations/0001_initial.py
Normal file
23
InvenTree/plugin/migrations/0001_initial.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-11-11 23:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PluginConfig',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('key', models.CharField(help_text='Key of plugin', max_length=255, unique=True, verbose_name='Key')),
|
||||||
|
('name', models.CharField(blank=True, help_text='PluginName of the plugin', max_length=255, null=True, verbose_name='Name')),
|
||||||
|
('active', models.BooleanField(default=False, help_text='Is the plugin active', verbose_name='Active')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-11-15 23:39
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('plugin', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='pluginconfig',
|
||||||
|
options={'verbose_name': 'Plugin Configuration', 'verbose_name_plural': 'Plugin Configurations'},
|
||||||
|
),
|
||||||
|
]
|
0
InvenTree/plugin/migrations/__init__.py
Normal file
0
InvenTree/plugin/migrations/__init__.py
Normal file
6
InvenTree/plugin/mixins/__init__.py
Normal file
6
InvenTree/plugin/mixins/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""utility class to enable simpler imports"""
|
||||||
|
from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin',
|
||||||
|
]
|
93
InvenTree/plugin/models.py
Normal file
93
InvenTree/plugin/models.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Plugin model definitions
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
|
||||||
|
class PluginConfig(models.Model):
|
||||||
|
""" A PluginConfig object holds settings for plugins.
|
||||||
|
|
||||||
|
It is used to designate a Part as 'subscribed' for a given User.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: slug of the plugin - must be unique
|
||||||
|
name: PluginName of the plugin - serves for a manual double check if the right plugin is used
|
||||||
|
active: Should the plugin be loaded?
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Plugin Configuration")
|
||||||
|
verbose_name_plural = _("Plugin Configurations")
|
||||||
|
|
||||||
|
key = models.CharField(
|
||||||
|
unique=True,
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('Key'),
|
||||||
|
help_text=_('Key of plugin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('Name'),
|
||||||
|
help_text=_('PluginName of the plugin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
active = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('Active'),
|
||||||
|
help_text=_('Is the plugin active'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
name = f'{self.name} - {self.key}'
|
||||||
|
if not self.active:
|
||||||
|
name += '(not active)'
|
||||||
|
return name
|
||||||
|
|
||||||
|
# extra attributes from the registry
|
||||||
|
def mixins(self):
|
||||||
|
return self.plugin._mixinreg
|
||||||
|
|
||||||
|
# functions
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""override to set original state of"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.__org_active = self.active
|
||||||
|
|
||||||
|
# append settings from registry
|
||||||
|
self.plugin = plugin_reg.plugins.get(self.key, None)
|
||||||
|
|
||||||
|
def get_plugin_meta(name):
|
||||||
|
if self.plugin:
|
||||||
|
return str(getattr(self.plugin, name, None))
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.meta = {
|
||||||
|
key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author',
|
||||||
|
'pub_date', 'version', 'website', 'license',
|
||||||
|
'package_path', 'settings_url', ]
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||||
|
"""extend save method to reload plugins if the 'active' status changes"""
|
||||||
|
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||||
|
|
||||||
|
ret = super().save(force_insert, force_update, *args, **kwargs)
|
||||||
|
|
||||||
|
if not reload:
|
||||||
|
if self.active is False and self.__org_active is True:
|
||||||
|
plugin_reg.reload_plugins()
|
||||||
|
|
||||||
|
elif self.active is True and self.__org_active is False:
|
||||||
|
plugin_reg.reload_plugins()
|
||||||
|
|
||||||
|
return ret
|
@ -1,15 +1,17 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Base Class for InvenTree plugins"""
|
||||||
|
|
||||||
|
|
||||||
class InvenTreePlugin():
|
class InvenTreePlugin():
|
||||||
"""
|
"""
|
||||||
Base class for a Barcode plugin
|
Base class for a plugin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Override the plugin name for each concrete plugin instance
|
# Override the plugin name for each concrete plugin instance
|
||||||
PLUGIN_NAME = ''
|
PLUGIN_NAME = ''
|
||||||
|
|
||||||
def plugin_name(self):
|
def plugin_name(self):
|
||||||
|
"""get plugin name"""
|
||||||
return self.PLUGIN_NAME
|
return self.PLUGIN_NAME
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
114
InvenTree/plugin/plugins.py
Normal file
114
InvenTree/plugin/plugins.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""general functions for plugin handeling"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
|
||||||
|
# Action plugins
|
||||||
|
import plugin.builtin.action as action
|
||||||
|
from plugin.action import ActionPlugin
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
|
|
||||||
|
def iter_namespace(pkg):
|
||||||
|
"""get all modules in a package"""
|
||||||
|
return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
|
||||||
|
|
||||||
|
|
||||||
|
def get_modules(pkg, recursive: bool = False):
|
||||||
|
"""get all modules in a package"""
|
||||||
|
from plugin.helpers import log_plugin_error
|
||||||
|
|
||||||
|
if not recursive:
|
||||||
|
return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
|
||||||
|
try:
|
||||||
|
module = loader.find_module(name).load_module(name)
|
||||||
|
pkg_names = getattr(module, '__all__', None)
|
||||||
|
for k, v in vars(module).items():
|
||||||
|
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
|
||||||
|
context[k] = v
|
||||||
|
context[name] = module
|
||||||
|
except AppRegistryNotReady:
|
||||||
|
pass
|
||||||
|
except Exception as error:
|
||||||
|
# this 'protects' against malformed plugin modules by more or less silently failing
|
||||||
|
|
||||||
|
# log to stack
|
||||||
|
log_plugin_error({name: str(error)}, 'discovery')
|
||||||
|
|
||||||
|
return [v for k, v in context.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_classes(module):
|
||||||
|
"""get all classes in a given module"""
|
||||||
|
return inspect.getmembers(module, inspect.isclass)
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugins(pkg, baseclass, recursive: bool = False):
|
||||||
|
"""
|
||||||
|
Return a list of all modules under a given package.
|
||||||
|
|
||||||
|
- Modules must be a subclass of the provided 'baseclass'
|
||||||
|
- Modules must have a non-empty PLUGIN_NAME parameter
|
||||||
|
"""
|
||||||
|
|
||||||
|
plugins = []
|
||||||
|
|
||||||
|
modules = get_modules(pkg, recursive)
|
||||||
|
|
||||||
|
# Iterate through each module in the package
|
||||||
|
for mod in modules:
|
||||||
|
# Iterate through each class in the module
|
||||||
|
for item in get_classes(mod):
|
||||||
|
plugin = item[1]
|
||||||
|
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
|
||||||
|
plugins.append(plugin)
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def load_plugins(name: str, cls, module):
|
||||||
|
"""general function to load a plugin class
|
||||||
|
|
||||||
|
:param name: name of the plugin for logs
|
||||||
|
:type name: str
|
||||||
|
:param module: module from which the plugins should be loaded
|
||||||
|
:return: class of the to-be-loaded plugin
|
||||||
|
"""
|
||||||
|
logger.debug("Loading %s plugins", name)
|
||||||
|
|
||||||
|
plugins = get_plugins(module, cls)
|
||||||
|
|
||||||
|
if len(plugins) > 0:
|
||||||
|
logger.info("Discovered %i %s plugins:", len(plugins), name)
|
||||||
|
|
||||||
|
for plugin in plugins:
|
||||||
|
logger.debug(" - %s", plugin.PLUGIN_NAME)
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def load_action_plugins():
|
||||||
|
"""
|
||||||
|
Return a list of all registered action plugins
|
||||||
|
"""
|
||||||
|
return load_plugins('action', ActionPlugin, action)
|
||||||
|
|
||||||
|
|
||||||
|
def load_barcode_plugins():
|
||||||
|
"""
|
||||||
|
Return a list of all registered barcode plugins
|
||||||
|
"""
|
||||||
|
from barcodes import plugins as BarcodePlugins
|
||||||
|
from barcodes.barcode import BarcodePlugin
|
||||||
|
|
||||||
|
return load_plugins('barcode', BarcodePlugin, BarcodePlugins)
|
452
InvenTree/plugin/registry.py
Normal file
452
InvenTree/plugin/registry.py
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
"""
|
||||||
|
registry for plugins
|
||||||
|
holds the class and the object that contains all code to maintain plugin states
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
import pathlib
|
||||||
|
import logging
|
||||||
|
from typing import OrderedDict
|
||||||
|
from importlib import reload
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
from django.conf.urls import url
|
||||||
|
from django.urls import clear_url_caches
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
try:
|
||||||
|
from importlib import metadata
|
||||||
|
except:
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
# TODO remove when python minimum is 3.8
|
||||||
|
|
||||||
|
from maintenance_mode.core import maintenance_mode_on
|
||||||
|
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
|
||||||
|
|
||||||
|
from plugin import plugins as inventree_plugins
|
||||||
|
from .integration import IntegrationPluginBase
|
||||||
|
from .helpers import get_plugin_error, IntegrationPluginError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
class Plugins:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# plugin registry
|
||||||
|
self.plugins = {}
|
||||||
|
self.plugins_inactive = {}
|
||||||
|
|
||||||
|
self.plugin_modules = [] # Holds all discovered plugins
|
||||||
|
|
||||||
|
self.errors = {} # Holds discovering errors
|
||||||
|
|
||||||
|
# flags
|
||||||
|
self.is_loading = False
|
||||||
|
self.apps_loading = True # Marks if apps were reloaded yet
|
||||||
|
|
||||||
|
# integration specific
|
||||||
|
self.installed_apps = [] # Holds all added plugin_paths
|
||||||
|
# mixins
|
||||||
|
self.mixins_globalsettings = {}
|
||||||
|
|
||||||
|
# region public plugin functions
|
||||||
|
def load_plugins(self):
|
||||||
|
"""load and activate all IntegrationPlugins"""
|
||||||
|
from plugin.helpers import log_plugin_error
|
||||||
|
|
||||||
|
logger.info('Start loading plugins')
|
||||||
|
# set maintanace mode
|
||||||
|
_maintenance = bool(get_maintenance_mode())
|
||||||
|
if not _maintenance:
|
||||||
|
set_maintenance_mode(True)
|
||||||
|
|
||||||
|
registered_sucessfull = False
|
||||||
|
blocked_plugin = None
|
||||||
|
retry_counter = settings.PLUGIN_RETRY
|
||||||
|
while not registered_sucessfull:
|
||||||
|
try:
|
||||||
|
# we are using the db so for migrations etc we need to try this block
|
||||||
|
self._init_plugins(blocked_plugin)
|
||||||
|
self._activate_plugins()
|
||||||
|
registered_sucessfull = True
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
# Exception if the database has not been migrated yet
|
||||||
|
logger.info('Database not accessible while loading plugins')
|
||||||
|
break
|
||||||
|
except IntegrationPluginError as error:
|
||||||
|
logger.error(f'[PLUGIN] Encountered an error with {error.path}:\n{error.message}')
|
||||||
|
log_plugin_error({error.path: error.message}, 'load')
|
||||||
|
blocked_plugin = error.path # we will not try to load this app again
|
||||||
|
|
||||||
|
# init apps without any integration plugins
|
||||||
|
self._clean_registry()
|
||||||
|
self._clean_installed_apps()
|
||||||
|
self._activate_plugins(force_reload=True)
|
||||||
|
|
||||||
|
# we do not want to end in an endless loop
|
||||||
|
retry_counter -= 1
|
||||||
|
if retry_counter <= 0:
|
||||||
|
if settings.PLUGIN_TESTING:
|
||||||
|
print('[PLUGIN] Max retries, breaking loading')
|
||||||
|
# TODO error for server status
|
||||||
|
break
|
||||||
|
if settings.PLUGIN_TESTING:
|
||||||
|
print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left')
|
||||||
|
|
||||||
|
# now the loading will re-start up with init
|
||||||
|
|
||||||
|
# remove maintenance
|
||||||
|
if not _maintenance:
|
||||||
|
set_maintenance_mode(False)
|
||||||
|
logger.info('Finished loading plugins')
|
||||||
|
|
||||||
|
def unload_plugins(self):
|
||||||
|
"""unload and deactivate all IntegrationPlugins"""
|
||||||
|
logger.info('Start unloading plugins')
|
||||||
|
# set maintanace mode
|
||||||
|
_maintenance = bool(get_maintenance_mode())
|
||||||
|
if not _maintenance:
|
||||||
|
set_maintenance_mode(True)
|
||||||
|
|
||||||
|
# remove all plugins from registry
|
||||||
|
self._clean_registry()
|
||||||
|
|
||||||
|
# deactivate all integrations
|
||||||
|
self._deactivate_plugins()
|
||||||
|
|
||||||
|
# remove maintenance
|
||||||
|
if not _maintenance:
|
||||||
|
set_maintenance_mode(False)
|
||||||
|
logger.info('Finished unloading plugins')
|
||||||
|
|
||||||
|
def reload_plugins(self):
|
||||||
|
"""safely reload IntegrationPlugins"""
|
||||||
|
# do not reload whe currently loading
|
||||||
|
if self.is_loading:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info('Start reloading plugins')
|
||||||
|
with maintenance_mode_on():
|
||||||
|
self.unload_plugins()
|
||||||
|
self.load_plugins()
|
||||||
|
logger.info('Finished reloading plugins')
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# region general plugin managment mechanisms
|
||||||
|
def collect_plugins(self):
|
||||||
|
"""collect integration plugins from all possible ways of loading"""
|
||||||
|
self.plugin_modules = [] # clear
|
||||||
|
|
||||||
|
# Collect plugins from paths
|
||||||
|
for plugin in settings.PLUGIN_DIRS:
|
||||||
|
modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
|
||||||
|
if modules:
|
||||||
|
[self.plugin_modules.append(item) for item in modules]
|
||||||
|
|
||||||
|
# check if not running in testing mode and apps should be loaded from hooks
|
||||||
|
if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
|
||||||
|
# Collect plugins from setup entry points
|
||||||
|
for entry in metadata.entry_points().get('inventree_plugins', []):
|
||||||
|
plugin = entry.load()
|
||||||
|
plugin.is_package = True
|
||||||
|
self.plugin_modules.append(plugin)
|
||||||
|
|
||||||
|
# Log collected plugins
|
||||||
|
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
|
||||||
|
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
||||||
|
|
||||||
|
def _init_plugins(self, disabled=None):
|
||||||
|
"""initialise all found plugins
|
||||||
|
|
||||||
|
:param disabled: loading path of disabled app, defaults to None
|
||||||
|
:type disabled: str, optional
|
||||||
|
:raises error: IntegrationPluginError
|
||||||
|
"""
|
||||||
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
|
logger.info('Starting plugin initialisation')
|
||||||
|
# Initialize integration plugins
|
||||||
|
for plugin in self.plugin_modules:
|
||||||
|
# check if package
|
||||||
|
was_packaged = getattr(plugin, 'is_package', False)
|
||||||
|
|
||||||
|
# check if activated
|
||||||
|
# these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
||||||
|
plug_name = plugin.PLUGIN_NAME
|
||||||
|
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
|
||||||
|
plug_key = slugify(plug_key) # keys are slugs!
|
||||||
|
try:
|
||||||
|
plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
|
||||||
|
except (OperationalError, ProgrammingError) as error:
|
||||||
|
# Exception if the database has not been migrated yet - check if test are running - raise if not
|
||||||
|
if not settings.PLUGIN_TESTING:
|
||||||
|
raise error
|
||||||
|
plugin_db_setting = None
|
||||||
|
|
||||||
|
# always activate if testing
|
||||||
|
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
|
||||||
|
# check if the plugin was blocked -> threw an error
|
||||||
|
if disabled:
|
||||||
|
# option1: package, option2: file-based
|
||||||
|
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
|
||||||
|
# errors are bad so disable the plugin in the database
|
||||||
|
if not settings.PLUGIN_TESTING:
|
||||||
|
plugin_db_setting.active = False
|
||||||
|
# TODO save the error to the plugin
|
||||||
|
plugin_db_setting.save(no_reload=True)
|
||||||
|
|
||||||
|
# add to inactive plugins so it shows up in the ui
|
||||||
|
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||||
|
continue # continue -> the plugin is not loaded
|
||||||
|
|
||||||
|
# init package
|
||||||
|
# now we can be sure that an admin has activated the plugin
|
||||||
|
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
|
||||||
|
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
|
||||||
|
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
|
||||||
|
try:
|
||||||
|
plugin = plugin()
|
||||||
|
except Exception as error:
|
||||||
|
# log error and raise it -> disable plugin
|
||||||
|
get_plugin_error(error, do_raise=True, do_log=True, log_name='init')
|
||||||
|
|
||||||
|
logger.info(f'Loaded integration plugin {plugin.slug}')
|
||||||
|
plugin.is_package = was_packaged
|
||||||
|
if plugin_db_setting:
|
||||||
|
plugin.pk = plugin_db_setting.pk
|
||||||
|
|
||||||
|
# safe reference
|
||||||
|
self.plugins[plugin.slug] = plugin
|
||||||
|
else:
|
||||||
|
# save for later reference
|
||||||
|
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||||
|
|
||||||
|
def _activate_plugins(self, force_reload=False):
|
||||||
|
"""run integration functions for all plugins
|
||||||
|
|
||||||
|
:param force_reload: force reload base apps, defaults to False
|
||||||
|
:type force_reload: bool, optional
|
||||||
|
"""
|
||||||
|
# activate integrations
|
||||||
|
plugins = self.plugins.items()
|
||||||
|
logger.info(f'Found {len(plugins)} active plugins')
|
||||||
|
|
||||||
|
self.activate_integration_globalsettings(plugins)
|
||||||
|
self.activate_integration_app(plugins, force_reload=force_reload)
|
||||||
|
|
||||||
|
def _deactivate_plugins(self):
|
||||||
|
"""run integration deactivation functions for all plugins"""
|
||||||
|
self.deactivate_integration_app()
|
||||||
|
self.deactivate_integration_globalsettings()
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# region specific integrations
|
||||||
|
# region integration_globalsettings
|
||||||
|
def activate_integration_globalsettings(self, plugins):
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
|
||||||
|
logger.info('Registering IntegrationPlugin global settings')
|
||||||
|
for slug, plugin in plugins:
|
||||||
|
if plugin.mixin_enabled('globalsettings'):
|
||||||
|
plugin_setting = plugin.globalsettingspatterns
|
||||||
|
self.mixins_globalsettings[slug] = plugin_setting
|
||||||
|
|
||||||
|
# Add to settings dir
|
||||||
|
InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
|
||||||
|
|
||||||
|
def deactivate_integration_globalsettings(self):
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
# collect all settings
|
||||||
|
plugin_settings = {}
|
||||||
|
for _, plugin_setting in self.mixins_globalsettings.items():
|
||||||
|
plugin_settings.update(plugin_setting)
|
||||||
|
|
||||||
|
# remove settings
|
||||||
|
for setting in plugin_settings:
|
||||||
|
InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
|
||||||
|
|
||||||
|
# clear cache
|
||||||
|
self.mixins_globalsettings = {}
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# region integration_app
|
||||||
|
def activate_integration_app(self, plugins, force_reload=False):
|
||||||
|
"""activate AppMixin plugins - add custom apps and reload
|
||||||
|
|
||||||
|
:param plugins: list of IntegrationPlugins that should be installed
|
||||||
|
:type plugins: dict
|
||||||
|
:param force_reload: only reload base apps, defaults to False
|
||||||
|
:type force_reload: bool, optional
|
||||||
|
"""
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
|
||||||
|
logger.info('Registering IntegrationPlugin apps')
|
||||||
|
apps_changed = False
|
||||||
|
|
||||||
|
# add them to the INSTALLED_APPS
|
||||||
|
for slug, plugin in plugins:
|
||||||
|
if plugin.mixin_enabled('app'):
|
||||||
|
plugin_path = self._get_plugin_path(plugin)
|
||||||
|
if plugin_path not in settings.INSTALLED_APPS:
|
||||||
|
settings.INSTALLED_APPS += [plugin_path]
|
||||||
|
self.installed_apps += [plugin_path]
|
||||||
|
apps_changed = True
|
||||||
|
|
||||||
|
# if apps were changed or force loading base apps -> reload
|
||||||
|
if apps_changed or force_reload:
|
||||||
|
# first startup or force loading of base apps -> registry is prob false
|
||||||
|
if self.apps_loading or force_reload:
|
||||||
|
self.apps_loading = False
|
||||||
|
self._reload_apps(force_reload=True)
|
||||||
|
else:
|
||||||
|
self._reload_apps()
|
||||||
|
|
||||||
|
# rediscover models/ admin sites
|
||||||
|
self._reregister_contrib_apps()
|
||||||
|
|
||||||
|
# update urls - must be last as models must be registered for creating admin routes
|
||||||
|
self._update_urls()
|
||||||
|
|
||||||
|
def _reregister_contrib_apps(self):
|
||||||
|
"""fix reloading of contrib apps - models and admin
|
||||||
|
this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
|
||||||
|
those register models and admin in their respective objects (e.g. admin.site for admin)
|
||||||
|
"""
|
||||||
|
for plugin_path in self.installed_apps:
|
||||||
|
try:
|
||||||
|
app_name = plugin_path.split('.')[-1]
|
||||||
|
app_config = apps.get_app_config(app_name)
|
||||||
|
except LookupError:
|
||||||
|
# the plugin was never loaded correctly
|
||||||
|
logger.debug(f'{app_name} App was not found during deregistering')
|
||||||
|
break
|
||||||
|
|
||||||
|
# reload models if they were set
|
||||||
|
# models_module gets set if models were defined - even after multiple loads
|
||||||
|
# on a reload the models registery is empty but models_module is not
|
||||||
|
if app_config.models_module and len(app_config.models) == 0:
|
||||||
|
reload(app_config.models_module)
|
||||||
|
|
||||||
|
# check for all models if they are registered with the site admin
|
||||||
|
model_not_reg = False
|
||||||
|
for model in app_config.get_models():
|
||||||
|
if not admin.site.is_registered(model):
|
||||||
|
model_not_reg = True
|
||||||
|
|
||||||
|
# reload admin if at least one model is not registered
|
||||||
|
# models are registered with admin in the 'admin.py' file - so we check
|
||||||
|
# if the app_config has an admin module before trying to laod it
|
||||||
|
if model_not_reg and hasattr(app_config.module, 'admin'):
|
||||||
|
reload(app_config.module.admin)
|
||||||
|
|
||||||
|
def _get_plugin_path(self, plugin):
|
||||||
|
"""parse plugin path
|
||||||
|
the input can be eiter:
|
||||||
|
- a local file / dir
|
||||||
|
- a package
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# for local path plugins
|
||||||
|
plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
|
||||||
|
except ValueError:
|
||||||
|
# plugin is shipped as package
|
||||||
|
plugin_path = plugin.PLUGIN_NAME
|
||||||
|
return plugin_path
|
||||||
|
|
||||||
|
def deactivate_integration_app(self):
|
||||||
|
"""deactivate integration app - some magic required"""
|
||||||
|
# unregister models from admin
|
||||||
|
for plugin_path in self.installed_apps:
|
||||||
|
models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed
|
||||||
|
app_name = plugin_path.split('.')[-1]
|
||||||
|
try:
|
||||||
|
app_config = apps.get_app_config(app_name)
|
||||||
|
|
||||||
|
# check all models
|
||||||
|
for model in app_config.get_models():
|
||||||
|
# remove model from admin site
|
||||||
|
admin.site.unregister(model)
|
||||||
|
models += [model._meta.model_name]
|
||||||
|
except LookupError:
|
||||||
|
# if an error occurs the app was never loaded right -> so nothing to do anymore
|
||||||
|
logger.debug(f'{app_name} App was not found during deregistering')
|
||||||
|
break
|
||||||
|
|
||||||
|
# unregister the models (yes, models are just kept in multilevel dicts)
|
||||||
|
for model in models:
|
||||||
|
# remove model from general registry
|
||||||
|
apps.all_models[plugin_path].pop(model)
|
||||||
|
|
||||||
|
# clear the registry for that app
|
||||||
|
# so that the import trick will work on reloading the same plugin
|
||||||
|
# -> the registry is kept for the whole lifecycle
|
||||||
|
if models and app_name in apps.all_models:
|
||||||
|
apps.all_models.pop(app_name)
|
||||||
|
|
||||||
|
# remove plugin from installed_apps
|
||||||
|
self._clean_installed_apps()
|
||||||
|
|
||||||
|
# reset load flag and reload apps
|
||||||
|
settings.INTEGRATION_APPS_LOADED = False
|
||||||
|
self._reload_apps()
|
||||||
|
|
||||||
|
# update urls to remove the apps from the site admin
|
||||||
|
self._update_urls()
|
||||||
|
|
||||||
|
def _clean_installed_apps(self):
|
||||||
|
for plugin in self.installed_apps:
|
||||||
|
if plugin in settings.INSTALLED_APPS:
|
||||||
|
settings.INSTALLED_APPS.remove(plugin)
|
||||||
|
|
||||||
|
self.installed_apps = []
|
||||||
|
|
||||||
|
def _clean_registry(self):
|
||||||
|
# remove all plugins from registry
|
||||||
|
self.plugins = {}
|
||||||
|
self.plugins_inactive = {}
|
||||||
|
|
||||||
|
def _update_urls(self):
|
||||||
|
from InvenTree.urls import urlpatterns
|
||||||
|
from plugin.urls import get_plugin_urls
|
||||||
|
|
||||||
|
for index, a in enumerate(urlpatterns):
|
||||||
|
if hasattr(a, 'app_name'):
|
||||||
|
if a.app_name == 'admin':
|
||||||
|
urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
|
||||||
|
elif a.app_name == 'plugin':
|
||||||
|
urlpatterns[index] = get_plugin_urls()
|
||||||
|
clear_url_caches()
|
||||||
|
|
||||||
|
def _reload_apps(self, force_reload: bool = False):
|
||||||
|
self.is_loading = True # set flag to disable loop reloading
|
||||||
|
if force_reload:
|
||||||
|
# we can not use the built in functions as we need to brute force the registry
|
||||||
|
apps.app_configs = OrderedDict()
|
||||||
|
apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
|
||||||
|
apps.clear_cache()
|
||||||
|
self._try_reload(apps.populate, settings.INSTALLED_APPS)
|
||||||
|
else:
|
||||||
|
self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
|
||||||
|
self.is_loading = False
|
||||||
|
|
||||||
|
def _try_reload(self, cmd, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
wrapper to try reloading the apps
|
||||||
|
throws an custom error that gets handled by the loading function
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cmd(*args, **kwargs)
|
||||||
|
return True, []
|
||||||
|
except Exception as error:
|
||||||
|
get_plugin_error(error, do_raise=True)
|
||||||
|
# endregion
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
plugins = Plugins()
|
0
InvenTree/plugin/samples/__init__.py
Normal file
0
InvenTree/plugin/samples/__init__.py
Normal file
0
InvenTree/plugin/samples/integration/__init__.py
Normal file
0
InvenTree/plugin/samples/integration/__init__.py
Normal file
19
InvenTree/plugin/samples/integration/another_sample.py
Normal file
19
InvenTree/plugin/samples/integration/another_sample.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""sample implementation for IntegrationPlugin"""
|
||||||
|
from plugin import IntegrationPluginBase
|
||||||
|
from plugin.mixins import UrlsMixin
|
||||||
|
|
||||||
|
|
||||||
|
class NoIntegrationPlugin(IntegrationPluginBase):
|
||||||
|
"""
|
||||||
|
An basic integration plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLUGIN_NAME = "NoIntegrationPlugin"
|
||||||
|
|
||||||
|
|
||||||
|
class WrongIntegrationPlugin(UrlsMixin, IntegrationPluginBase):
|
||||||
|
"""
|
||||||
|
An basic integration plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLUGIN_NAME = "WrongIntegrationPlugin"
|
11
InvenTree/plugin/samples/integration/broken_file.py
Normal file
11
InvenTree/plugin/samples/integration/broken_file.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""sample of a broken python file that will be ignored on import"""
|
||||||
|
from plugin import IntegrationPluginBase
|
||||||
|
|
||||||
|
|
||||||
|
class BrokenFileIntegrationPlugin(IntegrationPluginBase):
|
||||||
|
"""
|
||||||
|
An very broken integration plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
aaa = bb # noqa: F821
|
16
InvenTree/plugin/samples/integration/broken_sample.py
Normal file
16
InvenTree/plugin/samples/integration/broken_sample.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""sample of a broken integration plugin"""
|
||||||
|
from plugin import IntegrationPluginBase
|
||||||
|
|
||||||
|
|
||||||
|
class BrokenIntegrationPlugin(IntegrationPluginBase):
|
||||||
|
"""
|
||||||
|
An very broken integration plugin
|
||||||
|
"""
|
||||||
|
PLUGIN_NAME = 'Test'
|
||||||
|
PLUGIN_TITLE = 'Broken Plugin'
|
||||||
|
PLUGIN_SLUG = 'broken'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
raise KeyError('This is a dummy error')
|
48
InvenTree/plugin/samples/integration/sample.py
Normal file
48
InvenTree/plugin/samples/integration/sample.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""sample implementations for IntegrationPlugin"""
|
||||||
|
from plugin import IntegrationPluginBase
|
||||||
|
from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
|
|
||||||
|
class SampleIntegrationPlugin(AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
|
||||||
|
"""
|
||||||
|
An full integration plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLUGIN_NAME = "SampleIntegrationPlugin"
|
||||||
|
PLUGIN_SLUG = "sample"
|
||||||
|
PLUGIN_TITLE = "Sample Plugin"
|
||||||
|
|
||||||
|
NAVIGATION_TAB_NAME = "Sample Nav"
|
||||||
|
NAVIGATION_TAB_ICON = 'fas fa-plus'
|
||||||
|
|
||||||
|
def view_test(self, request):
|
||||||
|
"""very basic view"""
|
||||||
|
return HttpResponse(f'Hi there {request.user.username} this works')
|
||||||
|
|
||||||
|
def setup_urls(self):
|
||||||
|
he_urls = [
|
||||||
|
url(r'^he/', self.view_test, name='he'),
|
||||||
|
url(r'^ha/', self.view_test, name='ha'),
|
||||||
|
]
|
||||||
|
|
||||||
|
return [
|
||||||
|
url(r'^hi/', self.view_test, name='hi'),
|
||||||
|
url(r'^ho/', include(he_urls), name='ho'),
|
||||||
|
]
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
'PO_FUNCTION_ENABLE': {
|
||||||
|
'name': _('Enable PO'),
|
||||||
|
'description': _('Enable PO functionality in InvenTree interface'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
NAVIGATION = [
|
||||||
|
{'name': 'SampleIntegration', 'link': 'plugin:sample:hi'},
|
||||||
|
]
|
@ -0,0 +1,21 @@
|
|||||||
|
""" Unit tests for action plugins """
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
|
class SampleIntegrationPluginTests(TestCase):
|
||||||
|
""" Tests for SampleIntegrationPlugin """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Create a user for auth
|
||||||
|
user = get_user_model()
|
||||||
|
user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
|
def test_view(self):
|
||||||
|
"""check the function of the custom sample plugin """
|
||||||
|
response = self.client.get('/plugin/sample/ho/he/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.content, b'Hi there testuser this works')
|
119
InvenTree/plugin/serializers.py
Normal file
119
InvenTree/plugin/serializers.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
JSON serializers for Stock app
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||||
|
""" Serializer for a PluginConfig:
|
||||||
|
"""
|
||||||
|
|
||||||
|
meta = serializers.DictField(read_only=True)
|
||||||
|
mixins = serializers.DictField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PluginConfig
|
||||||
|
fields = [
|
||||||
|
'key',
|
||||||
|
'name',
|
||||||
|
'active',
|
||||||
|
'meta',
|
||||||
|
'mixins',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginConfigInstallSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for installing a new plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_('Source URL'),
|
||||||
|
help_text=_('Source for the package - this can be a custom registry or a VCS path')
|
||||||
|
)
|
||||||
|
packagename = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_('Package Name'),
|
||||||
|
help_text=_('Name for the Plugin Package - can also contain a version indicator'),
|
||||||
|
)
|
||||||
|
confirm = serializers.BooleanField(
|
||||||
|
label=_('Confirm plugin installation'),
|
||||||
|
help_text=_('This will install this plugin now into the current instance. The instance will go into maintenance.')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'url',
|
||||||
|
'packagename',
|
||||||
|
'confirm',
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
super().validate(data)
|
||||||
|
|
||||||
|
# check the base requirements are met
|
||||||
|
if not data.get('confirm'):
|
||||||
|
raise ValidationError({'confirm': _('Installation not confirmed')})
|
||||||
|
if (not data.get('url')) and (not data.get('packagename')):
|
||||||
|
msg = _('Either packagenmae of url must be provided')
|
||||||
|
raise ValidationError({'url': msg, 'packagename': msg})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
packagename = data.get('packagename', '')
|
||||||
|
url = data.get('url', '')
|
||||||
|
|
||||||
|
# build up the command
|
||||||
|
command = 'python -m pip install'.split()
|
||||||
|
|
||||||
|
if url:
|
||||||
|
# use custom registration / VCS
|
||||||
|
if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]:
|
||||||
|
# using a VCS provider
|
||||||
|
if packagename:
|
||||||
|
command.append(f'{packagename}@{url}')
|
||||||
|
else:
|
||||||
|
command.append(url)
|
||||||
|
else:
|
||||||
|
# using a custom package repositories
|
||||||
|
command.append('-i')
|
||||||
|
command.append(url)
|
||||||
|
command.append(packagename)
|
||||||
|
|
||||||
|
elif packagename:
|
||||||
|
# use pypi
|
||||||
|
command.append(packagename)
|
||||||
|
|
||||||
|
ret = {'command': ' '.join(command)}
|
||||||
|
# execute pypi
|
||||||
|
try:
|
||||||
|
result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR))
|
||||||
|
ret['result'] = str(result, 'utf-8')
|
||||||
|
ret['success'] = True
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
ret['result'] = str(error.output, 'utf-8')
|
||||||
|
ret['error'] = True
|
||||||
|
|
||||||
|
# register plugins
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
return ret
|
60
InvenTree/plugin/templatetags/plugin_extras.py
Normal file
60
InvenTree/plugin/templatetags/plugin_extras.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
""" This module provides template tags for handeling plugins
|
||||||
|
"""
|
||||||
|
from django.conf import settings as djangosettings
|
||||||
|
from django import template
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def plugin_list(*args, **kwargs):
|
||||||
|
""" Return a list of all installed integration plugins """
|
||||||
|
return plugin_reg.plugins
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def inactive_plugin_list(*args, **kwargs):
|
||||||
|
""" Return a list of all inactive integration plugins """
|
||||||
|
return plugin_reg.plugins_inactive
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def plugin_globalsettings(plugin, *args, **kwargs):
|
||||||
|
""" Return a list of all global settings for a plugin """
|
||||||
|
return plugin_reg.mixins_globalsettings.get(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def mixin_enabled(plugin, key, *args, **kwargs):
|
||||||
|
""" Return if the mixin is existant and configured in the plugin """
|
||||||
|
return plugin.mixin_enabled(key)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def navigation_enabled(*args, **kwargs):
|
||||||
|
"""Return if plugin navigation is enabled"""
|
||||||
|
if djangosettings.PLUGIN_TESTING:
|
||||||
|
return True
|
||||||
|
return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION')
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def safe_url(view_name, *args, **kwargs):
|
||||||
|
""" safe lookup for urls """
|
||||||
|
try:
|
||||||
|
return reverse(view_name, args=args, kwargs=kwargs)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def plugin_errors(*args, **kwargs):
|
||||||
|
"""Return all plugin errors"""
|
||||||
|
return plugin_reg.errors
|
61
InvenTree/plugin/test_action.py
Normal file
61
InvenTree/plugin/test_action.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
""" Unit tests for action plugins """
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from plugin.action import ActionPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class ActionPluginTests(TestCase):
|
||||||
|
""" Tests for ActionPlugin """
|
||||||
|
ACTION_RETURN = 'a action was performed'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.plugin = ActionPlugin('user')
|
||||||
|
|
||||||
|
class TestActionPlugin(ActionPlugin):
|
||||||
|
"""a action plugin"""
|
||||||
|
ACTION_NAME = 'abc123'
|
||||||
|
|
||||||
|
def perform_action(self):
|
||||||
|
return ActionPluginTests.ACTION_RETURN + 'action'
|
||||||
|
|
||||||
|
def get_result(self):
|
||||||
|
return ActionPluginTests.ACTION_RETURN + 'result'
|
||||||
|
|
||||||
|
def get_info(self):
|
||||||
|
return ActionPluginTests.ACTION_RETURN + 'info'
|
||||||
|
|
||||||
|
self.action_plugin = TestActionPlugin('user')
|
||||||
|
|
||||||
|
class NameActionPlugin(ActionPlugin):
|
||||||
|
PLUGIN_NAME = 'Aplugin'
|
||||||
|
|
||||||
|
self.action_name = NameActionPlugin('user')
|
||||||
|
|
||||||
|
def test_action_name(self):
|
||||||
|
"""check the name definition possibilities"""
|
||||||
|
self.assertEqual(self.plugin.action_name(), '')
|
||||||
|
self.assertEqual(self.action_plugin.action_name(), 'abc123')
|
||||||
|
self.assertEqual(self.action_name.action_name(), 'Aplugin')
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
"""check functions"""
|
||||||
|
# the class itself
|
||||||
|
self.assertIsNone(self.plugin.perform_action())
|
||||||
|
self.assertEqual(self.plugin.get_result(), False)
|
||||||
|
self.assertIsNone(self.plugin.get_info())
|
||||||
|
self.assertEqual(self.plugin.get_response(), {
|
||||||
|
"action": '',
|
||||||
|
"result": False,
|
||||||
|
"info": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# overriden functions
|
||||||
|
self.assertEqual(self.action_plugin.perform_action(), self.ACTION_RETURN + 'action')
|
||||||
|
self.assertEqual(self.action_plugin.get_result(), self.ACTION_RETURN + 'result')
|
||||||
|
self.assertEqual(self.action_plugin.get_info(), self.ACTION_RETURN + 'info')
|
||||||
|
self.assertEqual(self.action_plugin.get_response(), {
|
||||||
|
"action": 'abc123',
|
||||||
|
"result": self.ACTION_RETURN + 'result',
|
||||||
|
"info": self.ACTION_RETURN + 'info',
|
||||||
|
})
|
117
InvenTree/plugin/test_api.py
Normal file
117
InvenTree/plugin/test_api.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|
||||||
|
|
||||||
|
class PluginDetailAPITest(InvenTreeAPITestCase):
|
||||||
|
"""
|
||||||
|
Tests the plugin AP I endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'admin.add',
|
||||||
|
'admin.view',
|
||||||
|
'admin.change',
|
||||||
|
'admin.delete',
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.MSG_NO_PKG = 'Either packagenmae of url must be provided'
|
||||||
|
|
||||||
|
self.PKG_NAME = 'minimal'
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
def test_plugin_install(self):
|
||||||
|
"""
|
||||||
|
Test the plugin install command
|
||||||
|
"""
|
||||||
|
url = reverse('api-plugin-install')
|
||||||
|
|
||||||
|
# valid - Pypi
|
||||||
|
data = self.post(url, {
|
||||||
|
'confirm': True,
|
||||||
|
'packagename': self.PKG_NAME
|
||||||
|
}, expected_code=201).data
|
||||||
|
|
||||||
|
self.assertEqual(data['success'], True)
|
||||||
|
|
||||||
|
# invalid tries
|
||||||
|
# no input
|
||||||
|
self.post(url, {}, expected_code=400)
|
||||||
|
|
||||||
|
# no package info
|
||||||
|
data = self.post(url, {
|
||||||
|
'confirm': True,
|
||||||
|
}, expected_code=400).data
|
||||||
|
self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper())
|
||||||
|
self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper())
|
||||||
|
|
||||||
|
# not confirmed
|
||||||
|
self.post(url, {
|
||||||
|
'packagename': self.PKG_NAME
|
||||||
|
}, expected_code=400).data
|
||||||
|
data = self.post(url, {
|
||||||
|
'packagename': self.PKG_NAME,
|
||||||
|
'confirm': False,
|
||||||
|
}, expected_code=400).data
|
||||||
|
self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper())
|
||||||
|
|
||||||
|
def test_admin_action(self):
|
||||||
|
"""
|
||||||
|
Test the PluginConfig action commands
|
||||||
|
"""
|
||||||
|
from plugin.models import PluginConfig
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
url = reverse('admin:plugin_pluginconfig_changelist')
|
||||||
|
fixtures = PluginConfig.objects.all()
|
||||||
|
|
||||||
|
# check if plugins were registered -> in some test setups the startup has no db access
|
||||||
|
if not fixtures:
|
||||||
|
plugin_reg.reload_plugins()
|
||||||
|
fixtures = PluginConfig.objects.all()
|
||||||
|
|
||||||
|
print([str(a) for a in fixtures])
|
||||||
|
fixtures = fixtures[0:1]
|
||||||
|
# deactivate plugin
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'action': 'plugin_deactivate',
|
||||||
|
'index': 0,
|
||||||
|
'_selected_action': [f.pk for f in fixtures],
|
||||||
|
}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'action': 'plugin_deactivate',
|
||||||
|
'index': 0,
|
||||||
|
'_selected_action': [f.pk for f in fixtures],
|
||||||
|
}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# activate plugin
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'action': 'plugin_activate',
|
||||||
|
'index': 0,
|
||||||
|
'_selected_action': [f.pk for f in fixtures],
|
||||||
|
}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# activate everything
|
||||||
|
fixtures = PluginConfig.objects.all()
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'action': 'plugin_activate',
|
||||||
|
'index': 0,
|
||||||
|
'_selected_action': [f.pk for f in fixtures],
|
||||||
|
}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
fixtures = PluginConfig.objects.filter(active=True)
|
||||||
|
# save to deactivate a plugin
|
||||||
|
response = self.client.post(reverse('admin:plugin_pluginconfig_change', args=(fixtures.first().pk, )), {
|
||||||
|
'_save': 'Save',
|
||||||
|
}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
216
InvenTree/plugin/test_integration.py
Normal file
216
InvenTree/plugin/test_integration.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
""" Unit tests for integration plugins """
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from plugin import IntegrationPluginBase
|
||||||
|
from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
|
||||||
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMixinDefinition:
|
||||||
|
def test_mixin_name(self):
|
||||||
|
# mixin name
|
||||||
|
self.assertEqual(self.mixin.registered_mixins[0]['key'], self.MIXIN_NAME)
|
||||||
|
# human name
|
||||||
|
self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase):
|
||||||
|
MIXIN_HUMAN_NAME = 'Global settings'
|
||||||
|
MIXIN_NAME = 'globalsettings'
|
||||||
|
MIXIN_ENABLE_CHECK = 'has_globalsettings'
|
||||||
|
|
||||||
|
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
class SettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
|
||||||
|
GLOBALSETTINGS = self.TEST_SETTINGS
|
||||||
|
self.mixin = SettingsCls()
|
||||||
|
|
||||||
|
class NoSettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
|
||||||
|
pass
|
||||||
|
self.mixin_nothing = NoSettingsCls()
|
||||||
|
|
||||||
|
user = get_user_model()
|
||||||
|
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
self.test_user.is_staff = True
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
# settings variable
|
||||||
|
self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS)
|
||||||
|
|
||||||
|
# settings pattern
|
||||||
|
target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()}
|
||||||
|
self.assertEqual(self.mixin.globalsettingspatterns, target_pattern)
|
||||||
|
|
||||||
|
# no settings
|
||||||
|
self.assertIsNone(self.mixin_nothing.globalsettings)
|
||||||
|
self.assertIsNone(self.mixin_nothing.globalsettingspatterns)
|
||||||
|
|
||||||
|
# calling settings
|
||||||
|
# not existing
|
||||||
|
self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
|
||||||
|
self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
|
||||||
|
# right setting
|
||||||
|
self.mixin.set_globalsetting('SETTING1', '12345', self.test_user)
|
||||||
|
self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345')
|
||||||
|
# no setting
|
||||||
|
self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
|
||||||
|
|
||||||
|
|
||||||
|
class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||||
|
MIXIN_HUMAN_NAME = 'URLs'
|
||||||
|
MIXIN_NAME = 'urls'
|
||||||
|
MIXIN_ENABLE_CHECK = 'has_urls'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
class UrlsCls(UrlsMixin, IntegrationPluginBase):
|
||||||
|
def test():
|
||||||
|
return 'ccc'
|
||||||
|
URLS = [url('testpath', test, name='test'), ]
|
||||||
|
self.mixin = UrlsCls()
|
||||||
|
|
||||||
|
class NoUrlsCls(UrlsMixin, IntegrationPluginBase):
|
||||||
|
pass
|
||||||
|
self.mixin_nothing = NoUrlsCls()
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
plg_name = self.mixin.plugin_name()
|
||||||
|
|
||||||
|
# base_url
|
||||||
|
target_url = f'{PLUGIN_BASE}/{plg_name}/'
|
||||||
|
self.assertEqual(self.mixin.base_url, target_url)
|
||||||
|
|
||||||
|
# urlpattern
|
||||||
|
target_pattern = url(f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name)
|
||||||
|
self.assertEqual(self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict)
|
||||||
|
|
||||||
|
# resolve the view
|
||||||
|
self.assertEqual(self.mixin.urlpatterns.resolve('/testpath').func(), 'ccc')
|
||||||
|
self.assertEqual(self.mixin.urlpatterns.reverse('test'), 'testpath')
|
||||||
|
|
||||||
|
# no url
|
||||||
|
self.assertIsNone(self.mixin_nothing.urls)
|
||||||
|
self.assertIsNone(self.mixin_nothing.urlpatterns)
|
||||||
|
|
||||||
|
# internal name
|
||||||
|
self.assertEqual(self.mixin.internal_name, f'plugin:{self.mixin.slug}:')
|
||||||
|
|
||||||
|
|
||||||
|
class AppMixinTest(BaseMixinDefinition, TestCase):
|
||||||
|
MIXIN_HUMAN_NAME = 'App registration'
|
||||||
|
MIXIN_NAME = 'app'
|
||||||
|
MIXIN_ENABLE_CHECK = 'has_app'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
class TestCls(AppMixin, IntegrationPluginBase):
|
||||||
|
pass
|
||||||
|
self.mixin = TestCls()
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
# test that this plugin is in settings
|
||||||
|
self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)
|
||||||
|
|
||||||
|
|
||||||
|
class NavigationMixinTest(BaseMixinDefinition, TestCase):
|
||||||
|
MIXIN_HUMAN_NAME = 'Navigation Links'
|
||||||
|
MIXIN_NAME = 'navigation'
|
||||||
|
MIXIN_ENABLE_CHECK = 'has_naviation'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
class NavigationCls(NavigationMixin, IntegrationPluginBase):
|
||||||
|
NAVIGATION = [
|
||||||
|
{'name': 'aa', 'link': 'plugin:test:test_view'},
|
||||||
|
]
|
||||||
|
NAVIGATION_TAB_NAME = 'abcd1'
|
||||||
|
self.mixin = NavigationCls()
|
||||||
|
|
||||||
|
class NothingNavigationCls(NavigationMixin, IntegrationPluginBase):
|
||||||
|
pass
|
||||||
|
self.nothing_mixin = NothingNavigationCls()
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
# check right configuration
|
||||||
|
self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
|
||||||
|
# check wrong links fails
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
class NavigationCls(NavigationMixin, IntegrationPluginBase):
|
||||||
|
NAVIGATION = ['aa', 'aa']
|
||||||
|
NavigationCls()
|
||||||
|
|
||||||
|
# navigation name
|
||||||
|
self.assertEqual(self.mixin.navigation_name, 'abcd1')
|
||||||
|
self.assertEqual(self.nothing_mixin.navigation_name, '')
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationPluginBaseTests(TestCase):
|
||||||
|
""" Tests for IntegrationPluginBase """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.plugin = IntegrationPluginBase()
|
||||||
|
|
||||||
|
class SimpeIntegrationPluginBase(IntegrationPluginBase):
|
||||||
|
PLUGIN_NAME = 'SimplePlugin'
|
||||||
|
|
||||||
|
self.plugin_simple = SimpeIntegrationPluginBase()
|
||||||
|
|
||||||
|
class NameIntegrationPluginBase(IntegrationPluginBase):
|
||||||
|
PLUGIN_NAME = 'Aplugin'
|
||||||
|
PLUGIN_SLUG = 'a'
|
||||||
|
PLUGIN_TITLE = 'a titel'
|
||||||
|
PUBLISH_DATE = "1111-11-11"
|
||||||
|
AUTHOR = 'AA BB'
|
||||||
|
DESCRIPTION = 'A description'
|
||||||
|
VERSION = '1.2.3a'
|
||||||
|
WEBSITE = 'http://aa.bb/cc'
|
||||||
|
LICENSE = 'MIT'
|
||||||
|
|
||||||
|
self.plugin_name = NameIntegrationPluginBase()
|
||||||
|
|
||||||
|
def test_action_name(self):
|
||||||
|
"""check the name definition possibilities"""
|
||||||
|
# plugin_name
|
||||||
|
self.assertEqual(self.plugin.plugin_name(), '')
|
||||||
|
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
|
||||||
|
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
|
||||||
|
|
||||||
|
# slug
|
||||||
|
self.assertEqual(self.plugin.slug, '')
|
||||||
|
self.assertEqual(self.plugin_simple.slug, 'simpleplugin')
|
||||||
|
self.assertEqual(self.plugin_name.slug, 'a')
|
||||||
|
|
||||||
|
# human_name
|
||||||
|
self.assertEqual(self.plugin.human_name, '')
|
||||||
|
self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
|
||||||
|
self.assertEqual(self.plugin_name.human_name, 'a titel')
|
||||||
|
|
||||||
|
# description
|
||||||
|
self.assertEqual(self.plugin.description, '')
|
||||||
|
self.assertEqual(self.plugin_simple.description, 'SimplePlugin')
|
||||||
|
self.assertEqual(self.plugin_name.description, 'A description')
|
||||||
|
|
||||||
|
# author
|
||||||
|
self.assertEqual(self.plugin_name.author, 'AA BB')
|
||||||
|
|
||||||
|
# pub_date
|
||||||
|
self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0))
|
||||||
|
|
||||||
|
# version
|
||||||
|
self.assertEqual(self.plugin.version, None)
|
||||||
|
self.assertEqual(self.plugin_simple.version, None)
|
||||||
|
self.assertEqual(self.plugin_name.version, '1.2.3a')
|
||||||
|
|
||||||
|
# website
|
||||||
|
self.assertEqual(self.plugin.website, None)
|
||||||
|
self.assertEqual(self.plugin_simple.website, None)
|
||||||
|
self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
|
||||||
|
|
||||||
|
# license
|
||||||
|
self.assertEqual(self.plugin.license, None)
|
||||||
|
self.assertEqual(self.plugin_simple.license, None)
|
||||||
|
self.assertEqual(self.plugin_name.license, 'MIT')
|
92
InvenTree/plugin/test_plugin.py
Normal file
92
InvenTree/plugin/test_plugin.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
""" Unit tests for plugins """
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
import plugin.plugin
|
||||||
|
import plugin.integration
|
||||||
|
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||||
|
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
|
||||||
|
# from plugin.plugins import load_action_plugins, load_barcode_plugins
|
||||||
|
import plugin.templatetags.plugin_extras as plugin_tags
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreePluginTests(TestCase):
|
||||||
|
""" Tests for InvenTreePlugin """
|
||||||
|
def setUp(self):
|
||||||
|
self.plugin = plugin.plugin.InvenTreePlugin()
|
||||||
|
|
||||||
|
class NamedPlugin(plugin.plugin.InvenTreePlugin):
|
||||||
|
"""a named plugin"""
|
||||||
|
PLUGIN_NAME = 'abc123'
|
||||||
|
|
||||||
|
self.named_plugin = NamedPlugin()
|
||||||
|
|
||||||
|
def test_basic_plugin_init(self):
|
||||||
|
"""check if a basic plugin intis"""
|
||||||
|
self.assertEqual(self.plugin.PLUGIN_NAME, '')
|
||||||
|
self.assertEqual(self.plugin.plugin_name(), '')
|
||||||
|
|
||||||
|
def test_basic_plugin_name(self):
|
||||||
|
"""check if the name of a basic plugin can be set"""
|
||||||
|
self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
|
||||||
|
self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
|
||||||
|
|
||||||
|
|
||||||
|
class PluginIntegrationTests(TestCase):
|
||||||
|
""" Tests for general plugin functions """
|
||||||
|
|
||||||
|
def test_plugin_loading(self):
|
||||||
|
"""check if plugins load as expected"""
|
||||||
|
# plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()] # TODO refactor barcode plugin to support standard loading
|
||||||
|
# plugin_names_action = [a().plugin_name() for a in load_action_plugins()] # TODO refactor action plugin to support standard loading
|
||||||
|
|
||||||
|
# self.assertEqual(plugin_names_action, '')
|
||||||
|
# self.assertEqual(plugin_names_barcode, '')
|
||||||
|
|
||||||
|
# TODO remove test once loading is moved
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTagTests(TestCase):
|
||||||
|
""" Tests for the plugin extras """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.sample = SampleIntegrationPlugin()
|
||||||
|
self.plugin_no = NoIntegrationPlugin()
|
||||||
|
self.plugin_wrong = WrongIntegrationPlugin()
|
||||||
|
|
||||||
|
def test_tag_plugin_list(self):
|
||||||
|
"""test that all plugins are listed"""
|
||||||
|
self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins)
|
||||||
|
|
||||||
|
def test_tag_incative_plugin_list(self):
|
||||||
|
"""test that all inactive plugins are listed"""
|
||||||
|
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive)
|
||||||
|
|
||||||
|
def test_tag_plugin_globalsettings(self):
|
||||||
|
"""check all plugins are listed"""
|
||||||
|
self.assertEqual(
|
||||||
|
plugin_tags.plugin_globalsettings(self.sample),
|
||||||
|
plugin_reg.mixins_globalsettings.get(self.sample)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tag_mixin_enabled(self):
|
||||||
|
"""check that mixin enabled functions work"""
|
||||||
|
key = 'urls'
|
||||||
|
# mixin enabled
|
||||||
|
self.assertEqual(plugin_tags.mixin_enabled(self.sample, key), True)
|
||||||
|
# mixin not enabled
|
||||||
|
self.assertEqual(plugin_tags.mixin_enabled(self.plugin_wrong, key), False)
|
||||||
|
# mxixn not existing
|
||||||
|
self.assertEqual(plugin_tags.mixin_enabled(self.plugin_no, key), False)
|
||||||
|
|
||||||
|
def test_tag_safe_url(self):
|
||||||
|
"""test that the safe url tag works expected"""
|
||||||
|
# right url
|
||||||
|
self.assertEqual(plugin_tags.safe_url('api-plugin-install'), '/api/plugin/install/')
|
||||||
|
# wrong url
|
||||||
|
self.assertEqual(plugin_tags.safe_url('indexas'), None)
|
||||||
|
|
||||||
|
def test_tag_plugin_errors(self):
|
||||||
|
"""test that all errors are listed"""
|
||||||
|
self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors)
|
18
InvenTree/plugin/urls.py
Normal file
18
InvenTree/plugin/urls.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
URL lookup for plugin app
|
||||||
|
"""
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
|
from plugin import plugin_reg
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_BASE = 'plugin' # Constant for links
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_urls():
|
||||||
|
"""returns a urlpattern that can be integrated into the global urls"""
|
||||||
|
urls = []
|
||||||
|
for plugin in plugin_reg.plugins.values():
|
||||||
|
if plugin.mixin_enabled('urls'):
|
||||||
|
urls.append(plugin.urlpatterns)
|
||||||
|
return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
|
@ -1,69 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import importlib
|
|
||||||
import pkgutil
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Action plugins
|
|
||||||
import plugins.action as action
|
|
||||||
from plugins.action.action import ActionPlugin
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
|
||||||
|
|
||||||
|
|
||||||
def iter_namespace(pkg):
|
|
||||||
|
|
||||||
return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
|
|
||||||
|
|
||||||
|
|
||||||
def get_modules(pkg):
|
|
||||||
# Return all modules in a given package
|
|
||||||
return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
|
|
||||||
|
|
||||||
|
|
||||||
def get_classes(module):
|
|
||||||
# Return all classes in a given module
|
|
||||||
return inspect.getmembers(module, inspect.isclass)
|
|
||||||
|
|
||||||
|
|
||||||
def get_plugins(pkg, baseclass):
|
|
||||||
"""
|
|
||||||
Return a list of all modules under a given package.
|
|
||||||
|
|
||||||
- Modules must be a subclass of the provided 'baseclass'
|
|
||||||
- Modules must have a non-empty PLUGIN_NAME parameter
|
|
||||||
"""
|
|
||||||
|
|
||||||
plugins = []
|
|
||||||
|
|
||||||
modules = get_modules(pkg)
|
|
||||||
|
|
||||||
# Iterate through each module in the package
|
|
||||||
for mod in modules:
|
|
||||||
# Iterate through each class in the module
|
|
||||||
for item in get_classes(mod):
|
|
||||||
plugin = item[1]
|
|
||||||
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
|
|
||||||
plugins.append(plugin)
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
|
|
||||||
|
|
||||||
def load_action_plugins():
|
|
||||||
"""
|
|
||||||
Return a list of all registered action plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug("Loading action plugins")
|
|
||||||
|
|
||||||
plugins = get_plugins(action, ActionPlugin)
|
|
||||||
|
|
||||||
if len(plugins) > 0:
|
|
||||||
logger.info("Discovered {n} action plugins:".format(n=len(plugins)))
|
|
||||||
|
|
||||||
for ap in plugins:
|
|
||||||
logger.debug(" - {ap}".format(ap=ap.PLUGIN_NAME))
|
|
||||||
|
|
||||||
return plugins
|
|
@ -86,17 +86,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
|
||||||
"""
|
|
||||||
Instead of "deleting" the StockItem
|
|
||||||
(which may take a long time)
|
|
||||||
we instead schedule it for deletion at a later date.
|
|
||||||
|
|
||||||
The background worker will delete these in the future
|
|
||||||
"""
|
|
||||||
|
|
||||||
instance.mark_for_deletion()
|
|
||||||
|
|
||||||
|
|
||||||
class StockItemSerialize(generics.CreateAPIView):
|
class StockItemSerialize(generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
@ -174,6 +163,23 @@ class StockTransfer(StockAdjustView):
|
|||||||
serializer_class = StockSerializers.StockTransferSerializer
|
serializer_class = StockSerializers.StockTransferSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class StockAssign(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for assigning stock to a particular customer
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = StockItem.objects.all()
|
||||||
|
serializer_class = StockSerializers.StockAssignmentSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
|
ctx['request'] = self.request
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class StockLocationList(generics.ListCreateAPIView):
|
class StockLocationList(generics.ListCreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for list view of StockLocation objects:
|
API endpoint for list view of StockLocation objects:
|
||||||
@ -623,9 +629,6 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
|
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
# Do not expose StockItem objects which are scheduled for deletion
|
|
||||||
queryset = queryset.filter(scheduled_for_deletion=False)
|
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
@ -1188,6 +1191,7 @@ stock_api_urls = [
|
|||||||
url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
|
url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
|
||||||
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||||
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||||
|
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||||
|
|
||||||
# StockItemAttachment API endpoints
|
# StockItemAttachment API endpoints
|
||||||
url(r'^attachment/', include([
|
url(r'^attachment/', include([
|
||||||
|
@ -21,20 +21,6 @@ from part.models import Part
|
|||||||
from .models import StockLocation, StockItem, StockItemTracking
|
from .models import StockLocation, StockItem, StockItemTracking
|
||||||
|
|
||||||
|
|
||||||
class AssignStockItemToCustomerForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for manually assigning a StockItem to a Customer
|
|
||||||
|
|
||||||
TODO: This could be a simple API driven form!
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = StockItem
|
|
||||||
fields = [
|
|
||||||
'customer',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ReturnStockItemForm(HelperForm):
|
class ReturnStockItemForm(HelperForm):
|
||||||
"""
|
"""
|
||||||
Form for manually returning a StockItem into stock
|
Form for manually returning a StockItem into stock
|
||||||
|
47
InvenTree/stock/migrations/0071_auto_20211205_1733.py
Normal file
47
InvenTree/stock/migrations/0071_auto_20211205_1733.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-12-05 06:33
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
def delete_scheduled(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Delete all stock items which are marked as 'scheduled_for_deletion'.
|
||||||
|
|
||||||
|
The issue that this field was addressing has now been fixed,
|
||||||
|
and so we can all move on with our lives...
|
||||||
|
"""
|
||||||
|
|
||||||
|
StockItem = apps.get_model('stock', 'stockitem')
|
||||||
|
|
||||||
|
items = StockItem.objects.filter(scheduled_for_deletion=True)
|
||||||
|
|
||||||
|
logger.info(f"Removing {items.count()} stock items scheduled for deletion")
|
||||||
|
|
||||||
|
items.delete()
|
||||||
|
|
||||||
|
Task = apps.get_model('django_q', 'schedule')
|
||||||
|
|
||||||
|
Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0070_auto_20211128_0151'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
delete_scheduled,
|
||||||
|
reverse_code=reverse,
|
||||||
|
)
|
||||||
|
]
|
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-12-05 06:49
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0071_auto_20211205_1733'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='scheduled_for_deletion',
|
||||||
|
),
|
||||||
|
]
|
@ -212,18 +212,12 @@ class StockItem(MPTTModel):
|
|||||||
belongs_to=None,
|
belongs_to=None,
|
||||||
customer=None,
|
customer=None,
|
||||||
is_building=False,
|
is_building=False,
|
||||||
status__in=StockStatus.AVAILABLE_CODES,
|
status__in=StockStatus.AVAILABLE_CODES
|
||||||
scheduled_for_deletion=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# A query filter which can be used to filter StockItem objects which have expired
|
# A query filter which can be used to filter StockItem objects which have expired
|
||||||
EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
|
EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
|
||||||
|
|
||||||
def mark_for_deletion(self):
|
|
||||||
|
|
||||||
self.scheduled_for_deletion = True
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def update_serial_number(self):
|
def update_serial_number(self):
|
||||||
"""
|
"""
|
||||||
Update the 'serial_int' field, to be an integer representation of the serial number.
|
Update the 'serial_int' field, to be an integer representation of the serial number.
|
||||||
@ -615,12 +609,6 @@ class StockItem(MPTTModel):
|
|||||||
help_text=_('Select Owner'),
|
help_text=_('Select Owner'),
|
||||||
related_name='stock_items')
|
related_name='stock_items')
|
||||||
|
|
||||||
scheduled_for_deletion = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_('Scheduled for deletion'),
|
|
||||||
help_text=_('This StockItem will be deleted by the background worker'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_stale(self):
|
def is_stale(self):
|
||||||
"""
|
"""
|
||||||
Returns True if this Stock item is "stale".
|
Returns True if this Stock item is "stale".
|
||||||
@ -1327,7 +1315,7 @@ class StockItem(MPTTModel):
|
|||||||
self.quantity = quantity
|
self.quantity = quantity
|
||||||
|
|
||||||
if quantity == 0 and self.delete_on_deplete and self.can_delete():
|
if quantity == 0 and self.delete_on_deplete and self.can_delete():
|
||||||
self.mark_for_deletion()
|
self.delete()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
|
@ -28,6 +28,8 @@ from .models import StockItemTestResult
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
|
|
||||||
|
import company.models
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
@ -537,6 +539,127 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StockAssignmentItemSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for a single StockItem with in StockAssignment request.
|
||||||
|
|
||||||
|
Here, the particular StockItem is being assigned (manually) to a customer
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- item: StockItem object
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'item',
|
||||||
|
]
|
||||||
|
|
||||||
|
item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Stock Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_item(self, item):
|
||||||
|
|
||||||
|
# The item must currently be "in stock"
|
||||||
|
if not item.in_stock:
|
||||||
|
raise ValidationError(_("Item must be in stock"))
|
||||||
|
|
||||||
|
# The base part must be "salable"
|
||||||
|
if not item.part.salable:
|
||||||
|
raise ValidationError(_("Part must be salable"))
|
||||||
|
|
||||||
|
# The item must not be allocated to a sales order
|
||||||
|
if item.sales_order_allocations.count() > 0:
|
||||||
|
raise ValidationError(_("Item is allocated to a sales order"))
|
||||||
|
|
||||||
|
# The item must not be allocated to a build order
|
||||||
|
if item.allocations.count() > 0:
|
||||||
|
raise ValidationError(_("Item is allocated to a build order"))
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
class StockAssignmentSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for assigning one (or more) stock items to a customer.
|
||||||
|
|
||||||
|
This is a manual assignment process, separate for (for example) a Sales Order
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'items',
|
||||||
|
'customer',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
|
|
||||||
|
items = StockAssignmentItemSerializer(
|
||||||
|
many=True,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=company.models.Company.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Customer'),
|
||||||
|
help_text=_('Customer to assign stock items'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_customer(self, customer):
|
||||||
|
|
||||||
|
if customer and not customer.is_customer:
|
||||||
|
raise ValidationError(_('Selected company is not a customer'))
|
||||||
|
|
||||||
|
return customer
|
||||||
|
|
||||||
|
notes = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_('Notes'),
|
||||||
|
help_text=_('Stock assignment notes'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise ValidationError(_("A list of stock items must be provided"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
request = self.context['request']
|
||||||
|
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
customer = data['customer']
|
||||||
|
notes = data.get('notes', '')
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
stock_item = item['item']
|
||||||
|
|
||||||
|
stock_item.allocateToCustomer(
|
||||||
|
customer,
|
||||||
|
user=user,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for a single StockItem within a stock adjument request.
|
Serializer for a single StockItem within a stock adjument request.
|
||||||
|
@ -1,35 +1,2 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
|
||||||
|
|
||||||
|
|
||||||
def delete_old_stock_items():
|
|
||||||
"""
|
|
||||||
This function removes StockItem objects which have been marked for deletion.
|
|
||||||
|
|
||||||
Bulk "delete" operations for database entries with foreign-key relationships
|
|
||||||
can be pretty expensive, and thus can "block" the UI for a period of time.
|
|
||||||
|
|
||||||
Thus, instead of immediately deleting multiple StockItems, some UI actions
|
|
||||||
simply mark each StockItem as "scheduled for deletion".
|
|
||||||
|
|
||||||
The background worker then manually deletes these at a later stage
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
from stock.models import StockItem
|
|
||||||
except AppRegistryNotReady:
|
|
||||||
logger.info("Could not delete scheduled StockItems - AppRegistryNotReady")
|
|
||||||
return
|
|
||||||
|
|
||||||
items = StockItem.objects.filter(scheduled_for_deletion=True)
|
|
||||||
|
|
||||||
if items.count() > 0:
|
|
||||||
logger.info(f"Removing {items.count()} StockItem objects scheduled for deletion")
|
|
||||||
items.delete()
|
|
||||||
|
@ -568,11 +568,19 @@ $("#stock-convert").click(function() {
|
|||||||
|
|
||||||
{% if item.in_stock %}
|
{% if item.in_stock %}
|
||||||
$("#stock-assign-to-customer").click(function() {
|
$("#stock-assign-to-customer").click(function() {
|
||||||
launchModalForm("{% url 'stock-item-assign' item.id %}",
|
|
||||||
{
|
inventreeGet('{% url "api-stock-detail" item.pk %}', {}, {
|
||||||
reload: true,
|
success: function(response) {
|
||||||
|
assignStockToCustomer(
|
||||||
|
[response],
|
||||||
|
{
|
||||||
|
success: function() {
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#stock-move").click(function() {
|
$("#stock-move").click(function() {
|
||||||
|
@ -16,9 +16,9 @@ from InvenTree.status_codes import StockStatus
|
|||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
import company.models
|
||||||
from .models import StockItem, StockLocation
|
import part.models
|
||||||
from .tasks import delete_old_stock_items
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
|
|
||||||
class StockAPITestCase(InvenTreeAPITestCase):
|
class StockAPITestCase(InvenTreeAPITestCase):
|
||||||
@ -593,11 +593,7 @@ class StockItemDeletionTest(StockAPITestCase):
|
|||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
|
|
||||||
# Check there are no stock items scheduled for deletion
|
n = StockItem.objects.count()
|
||||||
self.assertEqual(
|
|
||||||
StockItem.objects.filter(scheduled_for_deletion=True).count(),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create and then delete a bunch of stock items
|
# Create and then delete a bunch of stock items
|
||||||
for idx in range(10):
|
for idx in range(10):
|
||||||
@ -615,9 +611,7 @@ class StockItemDeletionTest(StockAPITestCase):
|
|||||||
|
|
||||||
pk = response.data['pk']
|
pk = response.data['pk']
|
||||||
|
|
||||||
item = StockItem.objects.get(pk=pk)
|
self.assertEqual(StockItem.objects.count(), n + 1)
|
||||||
|
|
||||||
self.assertFalse(item.scheduled_for_deletion)
|
|
||||||
|
|
||||||
# Request deletion via the API
|
# Request deletion via the API
|
||||||
self.delete(
|
self.delete(
|
||||||
@ -625,19 +619,7 @@ class StockItemDeletionTest(StockAPITestCase):
|
|||||||
expected_code=204
|
expected_code=204
|
||||||
)
|
)
|
||||||
|
|
||||||
# There should be 100x StockItem objects marked for deletion
|
self.assertEqual(StockItem.objects.count(), n)
|
||||||
self.assertEqual(
|
|
||||||
StockItem.objects.filter(scheduled_for_deletion=True).count(),
|
|
||||||
10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Perform the actual delete (will take some time)
|
|
||||||
delete_old_stock_items()
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
StockItem.objects.filter(scheduled_for_deletion=True).count(),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StockTestResultTest(StockAPITestCase):
|
class StockTestResultTest(StockAPITestCase):
|
||||||
@ -751,3 +733,112 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
|
|
||||||
# Check that an attachment has been uploaded
|
# Check that an attachment has been uploaded
|
||||||
self.assertIsNotNone(response.data['attachment'])
|
self.assertIsNotNone(response.data['attachment'])
|
||||||
|
|
||||||
|
|
||||||
|
class StockAssignTest(StockAPITestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for the stock assignment API endpoint,
|
||||||
|
where stock items are manually assigned to a customer
|
||||||
|
"""
|
||||||
|
|
||||||
|
URL = reverse('api-stock-assign')
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
|
||||||
|
# Test with empty data
|
||||||
|
response = self.post(
|
||||||
|
self.URL,
|
||||||
|
data={},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(response.data['items']))
|
||||||
|
self.assertIn('This field is required', str(response.data['customer']))
|
||||||
|
|
||||||
|
# Test with an invalid customer
|
||||||
|
response = self.post(
|
||||||
|
self.URL,
|
||||||
|
data={
|
||||||
|
'customer': 999,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('object does not exist', str(response.data['customer']))
|
||||||
|
|
||||||
|
# Test with a company which is *not* a customer
|
||||||
|
response = self.post(
|
||||||
|
self.URL,
|
||||||
|
data={
|
||||||
|
'customer': 3,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('company is not a customer', str(response.data['customer']))
|
||||||
|
|
||||||
|
# Test with an empty items list
|
||||||
|
response = self.post(
|
||||||
|
self.URL,
|
||||||
|
data={
|
||||||
|
'items': [],
|
||||||
|
'customer': 4,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('A list of stock items must be provided', str(response.data))
|
||||||
|
|
||||||
|
stock_item = StockItem.objects.create(
|
||||||
|
part=part.models.Part.objects.get(pk=1),
|
||||||
|
status=StockStatus.DESTROYED,
|
||||||
|
quantity=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.URL,
|
||||||
|
data={
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': stock_item.pk,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'customer': 4,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Item must be in stock', str(response.data['items'][0]))
|
||||||
|
|
||||||
|
def test_valid(self):
|
||||||
|
|
||||||
|
stock_items = []
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
|
||||||
|
stock_item = StockItem.objects.create(
|
||||||
|
part=part.models.Part.objects.get(pk=25),
|
||||||
|
quantity=i + 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_items.append({
|
||||||
|
'item': stock_item.pk
|
||||||
|
})
|
||||||
|
|
||||||
|
customer = company.models.Company.objects.get(pk=4)
|
||||||
|
|
||||||
|
self.assertEqual(customer.assigned_stock.count(), 0)
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.URL,
|
||||||
|
data={
|
||||||
|
'items': stock_items,
|
||||||
|
'customer': 4,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.data['customer'], 4)
|
||||||
|
|
||||||
|
# 5 stock items should now have been assigned to this customer
|
||||||
|
self.assertEqual(customer.assigned_stock.count(), 5)
|
||||||
|
@ -332,8 +332,6 @@ class StockTest(TestCase):
|
|||||||
w1 = StockItem.objects.get(pk=100)
|
w1 = StockItem.objects.get(pk=100)
|
||||||
w2 = StockItem.objects.get(pk=101)
|
w2 = StockItem.objects.get(pk=101)
|
||||||
|
|
||||||
self.assertFalse(w2.scheduled_for_deletion)
|
|
||||||
|
|
||||||
# Take 25 units from w1 (there are only 10 in stock)
|
# Take 25 units from w1 (there are only 10 in stock)
|
||||||
w1.take_stock(30, None, notes='Took 30')
|
w1.take_stock(30, None, notes='Took 30')
|
||||||
|
|
||||||
@ -344,15 +342,6 @@ class StockTest(TestCase):
|
|||||||
# Take 25 units from w2 (will be deleted)
|
# Take 25 units from w2 (will be deleted)
|
||||||
w2.take_stock(30, None, notes='Took 30')
|
w2.take_stock(30, None, notes='Took 30')
|
||||||
|
|
||||||
# w2 should now be marked for future deletion
|
|
||||||
w2 = StockItem.objects.get(pk=101)
|
|
||||||
self.assertTrue(w2.scheduled_for_deletion)
|
|
||||||
|
|
||||||
from stock.tasks import delete_old_stock_items
|
|
||||||
|
|
||||||
# Now run the "background task" to delete these stock items
|
|
||||||
delete_old_stock_items()
|
|
||||||
|
|
||||||
# This StockItem should now have been deleted
|
# This StockItem should now have been deleted
|
||||||
with self.assertRaises(StockItem.DoesNotExist):
|
with self.assertRaises(StockItem.DoesNotExist):
|
||||||
w2 = StockItem.objects.get(pk=101)
|
w2 = StockItem.objects.get(pk=101)
|
||||||
|
@ -23,7 +23,6 @@ stock_item_detail_urls = [
|
|||||||
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
|
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
|
||||||
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
|
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
|
||||||
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
||||||
url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
|
|
||||||
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
|
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
|
||||||
url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
|
url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
|
||||||
|
|
||||||
|
@ -294,39 +294,6 @@ class StockLocationQRCode(QRCodeView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class StockItemAssignToCustomer(AjaxUpdateView):
|
|
||||||
"""
|
|
||||||
View for manually assigning a StockItem to a Customer
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = StockItem
|
|
||||||
ajax_form_title = _("Assign to Customer")
|
|
||||||
context_object_name = "item"
|
|
||||||
form_class = StockForms.AssignStockItemToCustomerForm
|
|
||||||
|
|
||||||
def validate(self, item, form, **kwargs):
|
|
||||||
|
|
||||||
customer = form.cleaned_data.get('customer', None)
|
|
||||||
|
|
||||||
if not customer:
|
|
||||||
form.add_error('customer', _('Customer must be specified'))
|
|
||||||
|
|
||||||
def save(self, item, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Assign the stock item to the customer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
customer = form.cleaned_data.get('customer', None)
|
|
||||||
|
|
||||||
if customer:
|
|
||||||
item = item.allocateToCustomer(
|
|
||||||
customer,
|
|
||||||
user=self.request.user
|
|
||||||
)
|
|
||||||
|
|
||||||
item.clearAllocations()
|
|
||||||
|
|
||||||
|
|
||||||
class StockItemReturnToStock(AjaxUpdateView):
|
class StockItemReturnToStock(AjaxUpdateView):
|
||||||
"""
|
"""
|
||||||
View for returning a stock item (which is assigned to a customer) to stock.
|
View for returning a stock item (which is assigned to a customer) to stock.
|
||||||
|
73
InvenTree/templates/503.html
Normal file
73
InvenTree/templates/503.html
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{% extends "skeleton.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta http-equiv="refresh" content="30">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
{% trans 'Site is in Maintenance' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_class %}login-screen{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<!--
|
||||||
|
Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
|
||||||
|
-->
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<div class='notification-area' id='alerts'>
|
||||||
|
<!-- Div for displayed alerts -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='main body-wrapper login-screen d-flex'>
|
||||||
|
|
||||||
|
|
||||||
|
<div class='login-container'>
|
||||||
|
<div class="row">
|
||||||
|
<div class='container-fluid'>
|
||||||
|
|
||||||
|
<div class='clearfix content-heading login-header d-flex flex-wrap'>
|
||||||
|
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<hr>
|
||||||
|
{% block content %}
|
||||||
|
{% trans 'The site is currently in maintenance and should be up again soon!' %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_base %}
|
||||||
|
<script type='text/javascript'>
|
||||||
|
$(document).ready(function () {
|
||||||
|
// notifications
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
showAlertOrCache(
|
||||||
|
'{{ message }}',
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
style: 'info',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
inventreeDocReady();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
13
InvenTree/templates/InvenTree/settings/mixins/settings.html
Normal file
13
InvenTree/templates/InvenTree/settings/mixins/settings.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load plugin_extras %}
|
||||||
|
|
||||||
|
<h4>{% trans "Settings" %}</h4>
|
||||||
|
{% plugin_globalsettings plugin_key as plugin_settings %}
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<tbody>
|
||||||
|
{% for setting in plugin_settings %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key=setting%}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
25
InvenTree/templates/InvenTree/settings/mixins/urls.html
Normal file
25
InvenTree/templates/InvenTree/settings/mixins/urls.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
<h4>{% trans "URLs" %}</h4>
|
||||||
|
{% define plugin.base_url as base %}
|
||||||
|
<p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "URL" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, entry in plugin.urlpatterns.reverse_dict.items %}{% if key %}
|
||||||
|
<tr>
|
||||||
|
<td>{{key}}</td>
|
||||||
|
<td>{{entry.1}}</td>
|
||||||
|
<td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
146
InvenTree/templates/InvenTree/settings/plugin.html
Normal file
146
InvenTree/templates/InvenTree/settings/plugin.html
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
{% extends "panel.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load plugin_extras %}
|
||||||
|
|
||||||
|
{% block label %}plugin{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% trans "Plugin Settings" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
{% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='table-responsive'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<tbody>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<div class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Plugin list" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
{% url 'admin:plugin_pluginconfig_changelist' as url %}
|
||||||
|
{% include "admin_button.html" with url=url %}
|
||||||
|
<button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='table-responsive'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Admin" %}</th>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Author" %}</th>
|
||||||
|
<th>{% trans "Date" %}</th>
|
||||||
|
<th>{% trans "Version" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% plugin_list as pl_list %}
|
||||||
|
{% for plugin_key, plugin in pl_list.items %}
|
||||||
|
{% mixin_enabled plugin 'urls' as urls %}
|
||||||
|
{% mixin_enabled plugin 'settings' as settings %}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if user.is_staff and perms.plugin.change_pluginconfig %}
|
||||||
|
{% url 'admin:plugin_pluginconfig_change' plugin.pk as url %}
|
||||||
|
{% include "admin_button.html" with url=url %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
|
||||||
|
{% define plugin.registered_mixins as mixin_list %}
|
||||||
|
|
||||||
|
{% if mixin_list %}
|
||||||
|
{% for mixin in mixin_list %}
|
||||||
|
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
|
||||||
|
<span class='badge bg-dark badge-right'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if plugin.website %}
|
||||||
|
<a href="{{ plugin.website }}"><i class="fas fa-globe"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ plugin.author }}</td>
|
||||||
|
<td>{{ plugin.pub_date }}</td>
|
||||||
|
<td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% inactive_plugin_list as in_pl_list %}
|
||||||
|
{% if in_pl_list %}
|
||||||
|
<tr><td colspan="5"></td></tr>
|
||||||
|
<tr><td colspan="5"><h6>{% trans 'Inactive plugins' %}</h6></td></tr>
|
||||||
|
{% for plugin_key, plugin in in_pl_list.items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if user.is_staff and perms.plugin.change_pluginconfig %}
|
||||||
|
{% url 'admin:plugin_pluginconfig_change' plugin.pk as url %}
|
||||||
|
{% include "admin_button.html" with url=url %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{plugin.name}}<span class="text-muted"> - {{plugin.key}}</span></td>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% plugin_errors as pl_errors %}
|
||||||
|
{% if pl_errors %}
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<div class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Plugin Error Stack" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='table-responsive'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Stage" %}</th>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Message" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for stage, errors in pl_errors.items %}
|
||||||
|
{% for error_detail in errors %}
|
||||||
|
{% for name, message in error_detail.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ stage }}</td>
|
||||||
|
<td>{{ name }}</td>
|
||||||
|
<td>{{ message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
137
InvenTree/templates/InvenTree/settings/plugin_settings.html
Normal file
137
InvenTree/templates/InvenTree/settings/plugin_settings.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
{% extends "panel.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load plugin_extras %}
|
||||||
|
|
||||||
|
{% block label %}plugin-{{plugin_key}}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% blocktrans with name=plugin.human_name %}Plugin details for {{name}}{% endblocktrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h4>{% trans "Plugin information" %}</h4>
|
||||||
|
<div class='table-responsive'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<col width='25'>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans "Name" %}</td>
|
||||||
|
<td>{{ plugin.human_name }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-user'></span></span></td>
|
||||||
|
<td>{% trans "Author" %}</td>
|
||||||
|
<td>{{ plugin.author }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans "Description" %}</td>
|
||||||
|
<td>{{ plugin.description }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
|
<td>{% trans "Date" %}</td>
|
||||||
|
<td>{{ plugin.pub_date }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-hashtag'></span></td>
|
||||||
|
<td>{% trans "Version" %}</td>
|
||||||
|
<td>
|
||||||
|
{% if plugin.version %}
|
||||||
|
{{ plugin.version }}{% include "clip.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans 'no version information supplied' %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if plugin.website %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-globe'></span></td>
|
||||||
|
<td>{% trans "Website" %}</td>
|
||||||
|
<td>{{ plugin.website }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if plugin.license %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-balance-scale'></span></td>
|
||||||
|
<td>{% trans "License" %}</td>
|
||||||
|
<td>{{ plugin.license }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if plugin.is_package == False %}
|
||||||
|
<p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h4>{% trans "Package information" %}</h4>
|
||||||
|
<div class='table-responsive'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
<col width='25'>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans "Installation method" %}</td>
|
||||||
|
<td>
|
||||||
|
{% if plugin.is_package %}
|
||||||
|
{% trans "This plugin was installed as a package" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "This plugin was found in a local InvenTree path" %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans "Installation path" %}</td>
|
||||||
|
<td>{{ plugin.package_path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if plugin.is_package == False %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-user'></span></td>
|
||||||
|
<td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
|
<td>{% trans "Commit Date" %}</td><td>{{ plugin.package.date }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-code-branch'></span></td>
|
||||||
|
<td>{% trans "Commit Hash" %}</td><td>{{ plugin.package.hash }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-envelope'></span></td>
|
||||||
|
<td>{% trans "Commit Message" %}</td><td>{{ plugin.package.message }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
|
||||||
|
<td>{% trans "Sign Status" %}</td>
|
||||||
|
<td class="bg-{{plugin.sign_color}}">{% if plugin.package.verified %}{{ plugin.package.verified }}: {% endif%}{{ plugin.sign_state.msg }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td>
|
||||||
|
<td>{% trans "Sign Key" %}</td>
|
||||||
|
<td class="bg-{{plugin.sign_color}}">{{ plugin.package.key }}{% include "clip.html" %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% mixin_enabled plugin 'globalsettings' as globalsettings %}
|
||||||
|
{% if globalsettings %}
|
||||||
|
{% include 'InvenTree/settings/mixins/settings.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% mixin_enabled plugin 'urls' as urls %}
|
||||||
|
{% if urls %}
|
||||||
|
{% include 'InvenTree/settings/mixins/urls.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -3,6 +3,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
{% load plugin_extras %}
|
||||||
|
|
||||||
{% block breadcrumb_list %}
|
{% block breadcrumb_list %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -38,6 +39,14 @@
|
|||||||
{% include "InvenTree/settings/build.html" %}
|
{% include "InvenTree/settings/build.html" %}
|
||||||
{% include "InvenTree/settings/po.html" %}
|
{% include "InvenTree/settings/po.html" %}
|
||||||
{% include "InvenTree/settings/so.html" %}
|
{% include "InvenTree/settings/so.html" %}
|
||||||
|
{% include "InvenTree/settings/plugin.html" %}
|
||||||
|
|
||||||
|
{% plugin_list as pl_list %}
|
||||||
|
{% for plugin_key, plugin in pl_list.items %}
|
||||||
|
{% if plugin.registered_mixins %}
|
||||||
|
{% include "InvenTree/settings/plugin_settings.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -313,6 +322,10 @@ $("#import-part").click(function() {
|
|||||||
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#install-plugin").click(function() {
|
||||||
|
installPlugin();
|
||||||
|
});
|
||||||
|
|
||||||
enableSidebar('settings');
|
enableSidebar('settings');
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
{% load plugin_extras %}
|
||||||
|
|
||||||
{% trans "User Settings" as text %}
|
{% trans "User Settings" as text %}
|
||||||
{% include "sidebar_header.html" with text=text icon='fa-user' %}
|
{% include "sidebar_header.html" with text=text icon='fa-user' %}
|
||||||
@ -46,4 +47,15 @@
|
|||||||
{% trans "Sales Orders" as text %}
|
{% trans "Sales Orders" as text %}
|
||||||
{% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %}
|
{% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %}
|
||||||
|
|
||||||
|
{% include "sidebar_header.html" with text="Plugin Settings" %}
|
||||||
|
|
||||||
|
{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %}
|
||||||
|
|
||||||
|
{% plugin_list as pl_list %}
|
||||||
|
{% for plugin_key, plugin in pl_list.items %}
|
||||||
|
{% if plugin.registered_mixins %}
|
||||||
|
{% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
@ -1,4 +1,4 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary' url='{{ url }}'>
|
<button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary admin-button' url='{{ url }}'>
|
||||||
<span class='fas fa-user-shield'></span>
|
<span class='fas fa-user-shield'></span>
|
||||||
</button>
|
</button>
|
@ -178,6 +178,7 @@
|
|||||||
<script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
|
<script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
|
<script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
|
<script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
|
<script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
|
<script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
|
||||||
|
|
||||||
|
26
InvenTree/templates/js/translated/plugin.js
Normal file
26
InvenTree/templates/js/translated/plugin.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
/* globals
|
||||||
|
constructForm,
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* exported
|
||||||
|
installPlugin,
|
||||||
|
*/
|
||||||
|
|
||||||
|
function installPlugin() {
|
||||||
|
constructForm(`/api/plugin/install/`, {
|
||||||
|
method: 'POST',
|
||||||
|
title: '{% trans "Install Plugin" %}',
|
||||||
|
fields: {
|
||||||
|
packagename: {},
|
||||||
|
url: {},
|
||||||
|
confirm: {},
|
||||||
|
},
|
||||||
|
onSuccess: function(data) {
|
||||||
|
msg = '{% trans "The Plugin was installed" %}';
|
||||||
|
showMessage(msg, {style: 'success', details: data.result, timeout: 30000});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -38,6 +38,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
assignStockToCustomer,
|
||||||
createNewStockItem,
|
createNewStockItem,
|
||||||
createStockLocation,
|
createStockLocation,
|
||||||
duplicateStockItem,
|
duplicateStockItem,
|
||||||
@ -533,13 +534,166 @@ function exportStock(params={}) {
|
|||||||
url += `&${key}=${params[key]}`;
|
url += `&${key}=${params[key]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(url);
|
|
||||||
location.href = url;
|
location.href = url;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign multiple stock items to a customer
|
||||||
|
*/
|
||||||
|
function assignStockToCustomer(items, options={}) {
|
||||||
|
|
||||||
|
// Generate HTML content for the form
|
||||||
|
var html = `
|
||||||
|
<table class='table table-striped table-condensed' id='stock-assign-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Part" %}</th>
|
||||||
|
<th>{% trans "Stock Item" %}</th>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (var idx = 0; idx < items.length; idx++) {
|
||||||
|
|
||||||
|
var item = items[idx];
|
||||||
|
|
||||||
|
var pk = item.pk;
|
||||||
|
|
||||||
|
var part = item.part_detail;
|
||||||
|
|
||||||
|
var thumbnail = thumbnailImage(part.thumbnail || part.image);
|
||||||
|
|
||||||
|
var status = stockStatusDisplay(item.status, {classes: 'float-right'});
|
||||||
|
|
||||||
|
var quantity = '';
|
||||||
|
|
||||||
|
if (item.serial && item.quantity == 1) {
|
||||||
|
quantity = `{% trans "Serial" %}: ${item.serial}`;
|
||||||
|
} else {
|
||||||
|
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity += status;
|
||||||
|
|
||||||
|
var location = locationDetail(item, false);
|
||||||
|
|
||||||
|
var buttons = `<div class='btn-group' role='group'>`;
|
||||||
|
|
||||||
|
buttons += makeIconButton(
|
||||||
|
'fa-times icon-red',
|
||||||
|
'button-stock-item-remove',
|
||||||
|
pk,
|
||||||
|
'{% trans "Remove row" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
buttons += '</div>';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr id='stock_item_${pk}' class='stock-item'row'>
|
||||||
|
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
|
||||||
|
<td id='stock_${pk}'>
|
||||||
|
<div id='div_id_items_item_${pk}'>
|
||||||
|
${quantity}
|
||||||
|
<div id='errors-items_item_${pk}'></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td id='location_${pk}'>${location}</td>
|
||||||
|
<td id='buttons_${pk}'>${buttons}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
|
||||||
|
constructForm('{% url "api-stock-assign" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
preFormContent: html,
|
||||||
|
fields: {
|
||||||
|
'customer': {
|
||||||
|
value: options.customer,
|
||||||
|
filters: {
|
||||||
|
is_customer: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'notes': {},
|
||||||
|
},
|
||||||
|
confirm: true,
|
||||||
|
confirmMessage: '{% trans "Confirm stock assignment" %}',
|
||||||
|
title: '{% trans "Assign Stock to Customer" %}',
|
||||||
|
afterRender: function(fields, opts) {
|
||||||
|
// Add button callbacks to remove rows
|
||||||
|
$(opts.modal).find('.button-stock-item-remove').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
$(opts.modal).find(`#stock_item_${pk}`).remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmit: function(fields, opts) {
|
||||||
|
|
||||||
|
// Extract data elements from the form
|
||||||
|
var data = {
|
||||||
|
customer: getFormFieldValue('customer', {}, opts),
|
||||||
|
notes: getFormFieldValue('notes', {}, opts),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var item_pk_values = [];
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
var pk = item.pk;
|
||||||
|
|
||||||
|
// Does the row exist in the form?
|
||||||
|
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
||||||
|
|
||||||
|
if (row.exists()) {
|
||||||
|
item_pk_values.push(pk);
|
||||||
|
|
||||||
|
data.items.push({
|
||||||
|
item: pk,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
opts.nested = {
|
||||||
|
'items': item_pk_values,
|
||||||
|
};
|
||||||
|
|
||||||
|
inventreePut(
|
||||||
|
'{% url "api-stock-assign" %}',
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
|
||||||
|
if (options.success) {
|
||||||
|
options.success(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
showApiError(xhr, opts.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform stock adjustments
|
* Perform stock adjustments
|
||||||
*/
|
*/
|
||||||
@ -777,7 +931,7 @@ function adjustStock(action, items, options={}) {
|
|||||||
// Does the row exist in the form?
|
// Does the row exist in the form?
|
||||||
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
||||||
|
|
||||||
if (row) {
|
if (row.exists()) {
|
||||||
|
|
||||||
item_pk_values.push(pk);
|
item_pk_values.push(pk);
|
||||||
|
|
||||||
@ -1098,7 +1252,7 @@ function locationDetail(row, showLink=true) {
|
|||||||
// StockItem has been assigned to a sales order
|
// StockItem has been assigned to a sales order
|
||||||
text = '{% trans "Assigned to Sales Order" %}';
|
text = '{% trans "Assigned to Sales Order" %}';
|
||||||
url = `/order/sales-order/${row.sales_order}/`;
|
url = `/order/sales-order/${row.sales_order}/`;
|
||||||
} else if (row.location) {
|
} else if (row.location && row.location_detail) {
|
||||||
text = row.location_detail.pathstring;
|
text = row.location_detail.pathstring;
|
||||||
url = `/stock/location/${row.location}/`;
|
url = `/stock/location/${row.location}/`;
|
||||||
} else {
|
} else {
|
||||||
@ -1721,6 +1875,17 @@ function loadStockTable(table, options) {
|
|||||||
stockAdjustment('move');
|
stockAdjustment('move');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#multi-item-assign').click(function() {
|
||||||
|
|
||||||
|
var items = $(table).bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
assignStockToCustomer(items, {
|
||||||
|
success: function() {
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$('#multi-item-order').click(function() {
|
$('#multi-item-order').click(function() {
|
||||||
var selections = $(table).bootstrapTable('getSelections');
|
var selections = $(table).bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
@ -308,9 +308,17 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
// Filters for PurchaseOrderLineItem table
|
// Filters for PurchaseOrderLineItem table
|
||||||
if (tableKey == 'purchaseorderlineitem') {
|
if (tableKey == 'purchaseorderlineitem') {
|
||||||
return {
|
return {
|
||||||
completed: {
|
pending: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Completed" %}',
|
title: '{% trans "Pending" %}',
|
||||||
|
},
|
||||||
|
received: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Received" %}',
|
||||||
|
},
|
||||||
|
order_status: {
|
||||||
|
title: '{% trans "Order status" %}',
|
||||||
|
options: purchaseOrderCodes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
{% load plugin_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||||
{% settings_value 'STICKY_HEADER' user=request.user as sticky %}
|
{% settings_value 'STICKY_HEADER' user=request.user as sticky %}
|
||||||
|
{% navigation_enabled as plugin_nav %}
|
||||||
{% inventree_demo_mode as demo %}
|
{% inventree_demo_mode as demo %}
|
||||||
|
|
||||||
<nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light">
|
<nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light">
|
||||||
@ -57,6 +59,29 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if plugin_nav %}
|
||||||
|
{% plugin_list as pl_list %}
|
||||||
|
{% for plugin_key, plugin in pl_list.items %}
|
||||||
|
{% mixin_enabled plugin 'navigation' as navigation %}
|
||||||
|
{% if navigation %}
|
||||||
|
<li class='nav-item dropdown'>
|
||||||
|
<a class='nav-link dropdown-toggle' data-bs-toggle="dropdown" aria-expanded="false" href='#'>
|
||||||
|
<span class='{{plugin.navigation_icon}} icon-header'></span>{{plugin.navigation_name}}
|
||||||
|
</a>
|
||||||
|
<ul class='dropdown-menu'>
|
||||||
|
{% for nav_item in plugin.navigation %}
|
||||||
|
{% safe_url nav_item.link as nav_link %}
|
||||||
|
{% if nav_link %}
|
||||||
|
<li><a href="{{ nav_link }}" class="dropdown-item"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% if demo %}
|
{% if demo %}
|
||||||
|
99
InvenTree/templates/skeleton.html
Normal file
99
InvenTree/templates/skeleton.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<!-- Required meta tags -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="57x57" href="{% static 'img/favicon/apple-icon-57x57.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="60x60" href="{% static 'img/favicon/apple-icon-60x60.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="72x72" href="{% static 'img/favicon/apple-icon-72x72.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="{% static 'img/favicon/apple-icon-76x76.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="114x114" href="{% static 'img/favicon/apple-icon-114x114.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="{% static 'img/favicon/apple-icon-120x120.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="144x144" href="{% static 'img/favicon/apple-icon-144x144.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="{% static 'img/favicon/apple-icon-152x152.png' %}">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'img/favicon/apple-icon-180x180.png' %}">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="{% static 'img/favicon/android-icon-192x192.png' %}">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
|
||||||
|
<link rel="manifest" href="{% static 'img/favicon/manifest.json' %}">
|
||||||
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
|
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- CSS -->
|
||||||
|
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'select2/css/select2.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
||||||
|
|
||||||
|
{% block head_css %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{% block css %}
|
||||||
|
{% endblock %}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<title>
|
||||||
|
{% block page_title %}
|
||||||
|
{% endblock %}
|
||||||
|
</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class='{% block body_class %}{% endblock %}'>
|
||||||
|
{% block body %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script type="text/javascript" src="{% static 'script/jquery_3.3.1_jquery.min.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/jquery-ui/jquery-ui.min.js' %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
|
||||||
|
{% block body_scripts_general %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 3rd party general js -->
|
||||||
|
<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'select2/js/select2.full.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
|
||||||
|
|
||||||
|
<!-- general InvenTree -->
|
||||||
|
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
||||||
|
{% block body_scripts_inventree %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<!-- fontawesome -->
|
||||||
|
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script>
|
||||||
|
|
||||||
|
{% block js_load %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_base %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -50,6 +50,7 @@
|
|||||||
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
||||||
|
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.stock.delete %}
|
{% if roles.stock.delete %}
|
||||||
|
@ -73,6 +73,7 @@ class RuleSet(models.Model):
|
|||||||
'socialaccount_socialaccount',
|
'socialaccount_socialaccount',
|
||||||
'socialaccount_socialapp',
|
'socialaccount_socialapp',
|
||||||
'socialaccount_socialtoken',
|
'socialaccount_socialtoken',
|
||||||
|
'plugin_pluginconfig'
|
||||||
],
|
],
|
||||||
'part_category': [
|
'part_category': [
|
||||||
'part_partcategory',
|
'part_partcategory',
|
||||||
|
@ -15,6 +15,7 @@ django-error-report==0.2.0 # Error report viewer for the admin interface
|
|||||||
django-filter==2.4.0 # Extended filtering options
|
django-filter==2.4.0 # Extended filtering options
|
||||||
django-formtools==2.3 # Form wizard tools
|
django-formtools==2.3 # Form wizard tools
|
||||||
django-import-export==2.5.0 # Data import / export for admin interface
|
django-import-export==2.5.0 # Data import / export for admin interface
|
||||||
|
django-maintenance-mode==0.16.1 # Shut down application while reloading etc.
|
||||||
django-markdownify==0.8.0 # Markdown rendering
|
django-markdownify==0.8.0 # Markdown rendering
|
||||||
django-markdownx==3.0.1 # Markdown form fields
|
django-markdownx==3.0.1 # Markdown form fields
|
||||||
django-money==1.1 # Django app for currency management
|
django-money==1.1 # Django app for currency management
|
||||||
@ -28,6 +29,7 @@ django-weasyprint==1.0.1 # django weasyprint integration
|
|||||||
djangorestframework==3.12.4 # DRF framework
|
djangorestframework==3.12.4 # DRF framework
|
||||||
flake8==3.8.3 # PEP checking
|
flake8==3.8.3 # PEP checking
|
||||||
gunicorn>=20.1.0 # Gunicorn web server
|
gunicorn>=20.1.0 # Gunicorn web server
|
||||||
|
importlib_metadata # Backport for importlib.metadata
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
inventree # Install the latest version of the InvenTree API python library
|
||||||
markdown==3.3.4 # Force particular version of markdown
|
markdown==3.3.4 # Force particular version of markdown
|
||||||
pep8-naming==0.11.1 # PEP naming convention extension
|
pep8-naming==0.11.1 # PEP naming convention extension
|
||||||
|
Loading…
Reference in New Issue
Block a user