Merge branch 'matmair/issue2279' of https://github.com/matmair/InvenTree into matmair/issue2279

This commit is contained in:
Matthias 2021-12-10 00:22:49 +01:00
commit c6b88c2f24
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
84 changed files with 3502 additions and 396 deletions

View File

@ -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
View 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
View File

@ -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/

View File

@ -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):

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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;
} }

View File

@ -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

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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

View 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',
]

View File

@ -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
View 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
View 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
View 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)

View 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

View 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",
},
}
)

View 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
View 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

View 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

View 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)

View 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')),
],
),
]

View File

@ -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'},
),
]

View File

View 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',
]

View 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

View File

@ -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
View 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)

View 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()

View File

View 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"

View 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

View 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')

View 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'},
]

View File

@ -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')

View 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

View 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

View 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',
})

View 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)

View 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')

View 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
View 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')))

View File

@ -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

View File

@ -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([

View File

@ -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

View 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,
)
]

View File

@ -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',
),
]

View File

@ -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:

View File

@ -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.

View File

@ -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()

View File

@ -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() {

View File

@ -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)

View File

@ -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)

View File

@ -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'),

View File

@ -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.

View 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>

View 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>

View 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>

View 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 %}

View 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View 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});
}
});
}

View File

@ -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');

View File

@ -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,
}, },
}; };
} }

View File

@ -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 %}

View 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>

View File

@ -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 %}

View File

@ -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',

View File

@ -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