diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index a86c437e05..684d2b76b4 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -27,6 +27,7 @@ from stock import models as stock_models from company.models import Company, SupplierPart from plugin.events import trigger_event +import InvenTree.helpers from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.helpers import decimal2string, increment, getSetting from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode @@ -414,16 +415,12 @@ class PurchaseOrder(Order): ) try: - if not (quantity % 1 == 0): - raise ValidationError({ - "quantity": _("Quantity must be an integer") - }) if quantity < 0: raise ValidationError({ "quantity": _("Quantity must be a positive number") }) - quantity = int(quantity) - except (ValueError, TypeError): + quantity = InvenTree.helpers.clean_decimal(quantity) + except TypeError: raise ValidationError({ "quantity": _("Invalid quantity provided") }) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index e2faedfd9e..64b8b8536f 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -451,8 +451,17 @@ class I18nStaticNode(StaticNode): replaces a variable named *lng* in the path with the current language """ def render(self, context): - self.path.var = self.path.var.format(lng=context.request.LANGUAGE_CODE) + + self.original = getattr(self, 'original', None) + + if not self.original: + # Store the original (un-rendered) path template, as it gets overwritten below + self.original = self.path.var + + self.path.var = self.original.format(lng=context.request.LANGUAGE_CODE) + ret = super().render(context) + return ret @@ -480,4 +489,5 @@ else: # change path to called ressource bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'" token.contents = ' '.join(bits) + return I18nStaticNode.handle_token(parser, token) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index a2087ee879..7306a30a3c 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -75,10 +75,18 @@ class ScheduleMixin: 'schedule': "I", # Schedule type (see django_q.Schedule) 'minutes': 30, # Number of minutes (only if schedule type = Minutes) 'repeats': 5, # Number of repeats (leave blank for 'forever') - } + }, + 'member_func': { + 'func': 'my_class_func', # Note, without the 'dot' notation, it will call a class member function + 'schedule': "H", # Once per hour + }, } Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + + Note: The 'func' argument can take two different forms: + - Dotted notation e.g. 'module.submodule.func' - calls a global function with the defined path + - Member notation e.g. 'my_func' (no dots!) - calls a member function of the calling class """ ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] @@ -94,11 +102,14 @@ class ScheduleMixin: def __init__(self): super().__init__() - self.add_mixin('schedule', 'has_scheduled_tasks', __class__) - self.scheduled_tasks = getattr(self, 'SCHEDULED_TASKS', {}) - + self.scheduled_tasks = self.get_scheduled_tasks() self.validate_scheduled_tasks() + self.add_mixin('schedule', 'has_scheduled_tasks', __class__) + + def get_scheduled_tasks(self): + return getattr(self, 'SCHEDULED_TASKS', {}) + @property def has_scheduled_tasks(self): """ @@ -158,18 +169,46 @@ class ScheduleMixin: task_name = self.get_task_name(key) - # If a matching scheduled task does not exist, create it! - if not Schedule.objects.filter(name=task_name).exists(): + if Schedule.objects.filter(name=task_name).exists(): + # Scheduled task already exists - continue! + continue - logger.info(f"Adding scheduled task '{task_name}'") + logger.info(f"Adding scheduled task '{task_name}'") + + func_name = task['func'].strip() + + if '.' in func_name: + """ + Dotted notation indicates that we wish to run a globally defined function, + from a specified Python module. + """ Schedule.objects.create( name=task_name, - func=task['func'], + func=func_name, schedule_type=task['schedule'], minutes=task.get('minutes', None), repeats=task.get('repeats', -1), ) + + else: + """ + Non-dotted notation indicates that we wish to call a 'member function' of the calling plugin. + + This is managed by the plugin registry itself. + """ + + slug = self.plugin_slug() + + Schedule.objects.create( + name=task_name, + func='plugin.registry.call_function', + args=f"'{slug}', '{func_name}'", + schedule_type=task['schedule'], + minutes=task.get('minutes', None), + repeats=task.get('repeats', -1), + ) + except (ProgrammingError, OperationalError): # Database might not yet be ready logger.warning("register_tasks failed, database not ready") diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 30735e7510..d6fd71d7c4 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -124,6 +124,12 @@ class PluginSetting(common.models.BaseInvenTreeSetting): so that we can pass the plugin instance """ + def is_bool(self, **kwargs): + + kwargs['plugin'] = self.plugin + + return super().is_bool(**kwargs) + @property def name(self): return self.__class__.get_setting_name(self.key, plugin=self.plugin) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index e31f3c6529..4b7f12bc6c 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -59,6 +59,22 @@ class PluginsRegistry: # mixins self.mixins_settings = {} + def call_plugin_function(self, slug, func, *args, **kwargs): + """ + Call a member function (named by 'func') of the plugin named by 'slug'. + + As this is intended to be run by the background worker, + we do not perform any try/except here. + + Instead, any error messages are returned to the worker. + """ + + plugin = self.plugins[slug] + + plugin_func = getattr(plugin, func) + + return plugin_func(*args, **kwargs) + # region public functions # region loading / unloading def load_plugins(self): @@ -557,3 +573,8 @@ class PluginsRegistry: registry = PluginsRegistry() + + +def call_function(plugin_name, function_name, *args, **kwargs): + """ Global helper function to call a specific member function of a plugin """ + return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs) diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index 825dab134f..c8b1c4c5d0 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -3,7 +3,7 @@ Sample plugin which supports task scheduling """ from plugin import IntegrationPluginBase -from plugin.mixins import ScheduleMixin +from plugin.mixins import ScheduleMixin, SettingsMixin # Define some simple tasks to perform @@ -15,7 +15,7 @@ def print_world(): print("World") -class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): +class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase): """ A sample plugin which provides support for scheduled tasks """ @@ -25,6 +25,11 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): PLUGIN_TITLE = "Scheduled Tasks" SCHEDULED_TASKS = { + 'member': { + 'func': 'member_func', + 'schedule': 'I', + 'minutes': 30, + }, 'hello': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'I', @@ -35,3 +40,21 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): 'schedule': 'H', }, } + + SETTINGS = { + 'T_OR_F': { + 'name': 'True or False', + 'description': 'Print true or false when running the periodic task', + 'validator': bool, + 'default': False, + }, + } + + def member_func(self, *args, **kwargs): + """ + A simple member function to demonstrate functionality + """ + + t_or_f = self.get_setting('T_OR_F') + + print(f"Called member_func - value is {t_or_f}") diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index b17b226f28..85e0b4ce94 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -4,10 +4,10 @@ {% load plugin_extras %} {% 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-cog' %} {% trans "Account Settings" as text %} -{% include "sidebar_item.html" with label='account' text=text icon="fa-cog" %} +{% include "sidebar_item.html" with label='account' text=text icon="fa-sign-in-alt" %} {% trans "Display Settings" as text %} {% include "sidebar_item.html" with label='user-display' text=text icon="fa-desktop" %} {% trans "Home Page" as text %} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 43e8d5ce62..50ae39df2f 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -835,7 +835,9 @@ function updateFieldValue(name, value, field, options) { // Find the named field element in the modal DOM function getFormFieldElement(name, options) { - var el = $(options.modal).find(`#id_${name}`); + var field_name = getFieldName(name, options); + + var el = $(options.modal).find(`#id_${field_name}`); if (!el.exists) { console.log(`ERROR: Could not find form element for field '${name}'`); @@ -1148,7 +1150,9 @@ function handleFormErrors(errors, fields, options) { /* * Add a rendered error message to the provided field */ -function addFieldErrorMessage(field_name, error_text, error_idx, options) { +function addFieldErrorMessage(name, error_text, error_idx, options) { + + field_name = getFieldName(name, options); // Add the 'form-field-error' class $(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error'); @@ -1226,7 +1230,9 @@ function addClearCallbacks(fields, options) { function addClearCallback(name, field, options) { - var el = $(options.modal).find(`#clear_${name}`); + var field_name = getFieldName(name, options); + + var el = $(options.modal).find(`#clear_${field_name}`); if (!el) { console.log(`WARNING: addClearCallback could not find field '${name}'`); @@ -1374,21 +1380,23 @@ function initializeRelatedFields(fields, options) { */ function addSecondaryModal(field, fields, options) { - var name = field.name; + var field_name = getFieldName(field.name, options); - var secondary = field.secondary; + var depth = options.depth || 0; var html = ` -
- ${secondary.label || secondary.title} +
+ ${field.secondary.label || field.secondary.title}
`; - $(options.modal).find(`label[for="id_${name}"]`).append(html); + $(options.modal).find(`label[for="id_${field_name}"]`).append(html); // Callback function when the secondary button is pressed - $(options.modal).find(`#btn-new-${name}`).click(function() { + $(options.modal).find(`#btn-new-${field_name}`).click(function() { + + var secondary = field.secondary; // Determine the API query URL var url = secondary.api_url || field.api_url; @@ -1409,16 +1417,24 @@ function addSecondaryModal(field, fields, options) { // Force refresh from the API, to get full detail inventreeGet(`${url}${data.pk}/`, {}, { success: function(responseData) { - - setRelatedFieldData(name, responseData, options); + setRelatedFieldData(field.name, responseData, options); } }); }; } + // Relinquish keyboard focus for this modal + $(options.modal).modal({ + keyboard: false, + }); + // Method should be "POST" for creation secondary.method = secondary.method || 'POST'; + secondary.modal = null; + + secondary.depth = depth + 1; + constructForm( url, secondary @@ -1757,6 +1773,20 @@ function renderModelData(name, model, data, parameters, options) { } +/* + * Construct a field name for the given field + */ +function getFieldName(name, options) { + var field_name = name; + + if (options.depth) { + field_name += `_${options.depth}`; + } + + return field_name; +} + + /* * Construct a single form 'field' for rendering in a form. * @@ -1783,7 +1813,7 @@ function constructField(name, parameters, options) { return constructCandyInput(name, parameters, options); } - var field_name = `id_${name}`; + var field_name = getFieldName(name, options); // Hidden inputs are rendered without label / help text / etc if (parameters.hidden) { @@ -1803,6 +1833,8 @@ function constructField(name, parameters, options) { var group = parameters.group; + var group_id = getFieldName(group, options); + var group_options = options.groups[group] || {}; // Are we starting a new group? @@ -1810,12 +1842,12 @@ function constructField(name, parameters, options) { if (parameters.group != options.current_group) { html += ` -
-
`; +
+
`; if (group_options.collapsible) { html += ` -
- + -
+
`; } @@ -1848,7 +1880,7 @@ function constructField(name, parameters, options) { html += parameters.before; } - html += `
`; + html += `
`; // Add a label if (!options.hideLabels) { @@ -1886,13 +1918,13 @@ function constructField(name, parameters, options) { } } - html += constructInput(name, parameters, options); + html += constructInput(field_name, parameters, options); if (extra) { if (!parameters.required) { html += ` - + `; } @@ -1909,7 +1941,7 @@ function constructField(name, parameters, options) { } // Div for error messages - html += `
`; + html += `
`; html += `
`; // controls diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index e8697ac656..539ce61912 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -127,6 +127,9 @@ function createNewModal(options={}) { $(modal_name).find('#modal-form-cancel').hide(); } + // Steal keyboard focus + $(modal_name).focus(); + // Return the "name" of the modal return modal_name; } @@ -372,6 +375,14 @@ function attachSelect(modal) { } +function attachBootstrapCheckbox(modal) { + /* Attach 'switch' functionality to any checkboxes on the form */ + + $(modal + ' .checkboxinput').addClass('form-check-input'); + $(modal + ' .checkboxinput').wrap(`
`); +} + + function loadingMessageContent() { /* Render a 'loading' message to display in a form * when waiting for a response from the server @@ -686,7 +697,9 @@ function injectModalForm(modal, form_html) { * Updates the HTML of the form content, and then applies some other updates */ $(modal).find('.modal-form-content').html(form_html); + attachSelect(modal); + attachBootstrapCheckbox(modal); } diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index 589792f052..cae870e722 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -121,12 +121,12 @@ {% if user.is_staff and not demo %}
  • {% trans "Admin" %}
  • {% endif %} +
  • {% trans "Settings" %}
  • {% trans "Logout" %}
  • {% else %}
  • {% trans "Login" %}
  • {% endif %}
    -
  • {% trans "Settings" %}
  • {% if system_healthy or not user.is_staff %} diff --git a/InvenTree/templates/sidebar_header.html b/InvenTree/templates/sidebar_header.html index bb909a03a8..1aac8c9f7b 100644 --- a/InvenTree/templates/sidebar_header.html +++ b/InvenTree/templates/sidebar_header.html @@ -3,6 +3,6 @@
    {% if icon %}{% endif %} - {% if text %}{% endif %} + {% if text %}{% endif %}
    \ No newline at end of file diff --git a/docker/dev-config.env b/docker/dev-config.env index b7ee4d8526..63a0afe4fb 100644 --- a/docker/dev-config.env +++ b/docker/dev-config.env @@ -14,4 +14,4 @@ INVENTREE_DB_USER=pguser INVENTREE_DB_PASSWORD=pgpassword # Enable plugins? -INVENTREE_PLUGINS_ENABLED=False +INVENTREE_PLUGINS_ENABLED=True diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 14a9c4bbfc..ca0f837142 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -45,6 +45,10 @@ services: ports: # Expose web server on port 8000 - 8000:8000 + # Note: If using the inventree-dev-proxy container (see below), + # comment out the "ports" directive (above) and uncomment the "expose" directive + #expose: + # - 8000 volumes: # Ensure you specify the location of the 'src' directory at the end of this file - src:/home/inventree @@ -70,6 +74,25 @@ services: - dev-config.env restart: unless-stopped + ### Optional: Serve static and media files using nginx + ### Uncomment the following lines to enable nginx proxy for testing + ### Note: If enabling the proxy, change "ports" to "expose" for the inventree-dev-server container (above) + #inventree-dev-proxy: + # container_name: inventree-dev-proxy + # image: nginx:stable + # depends_on: + # - inventree-dev-server + # ports: + # # Change "8000" to the port that you want InvenTree web server to be available on + # - 8000:80 + # volumes: + # # Provide ./nginx.conf file to the container + # # Refer to the provided example file as a starting point + # - ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro + # # nginx proxy needs access to static and media files + # - src:/var/www + # restart: unless-stopped + volumes: # NOTE: Change "../" to a directory on your local machine, where the InvenTree source code is located # Persistent data, stored external to the container(s) diff --git a/docker/nginx.dev.conf b/docker/nginx.dev.conf new file mode 100644 index 0000000000..8fc47e622c --- /dev/null +++ b/docker/nginx.dev.conf @@ -0,0 +1,57 @@ + +server { + + # Listen for connection on (internal) port 80 + listen 80; + + location / { + # Change 'inventree-dev-server' to the name of the inventree server container, + # and '8000' to the INVENTREE_WEB_PORT (if not default) + proxy_pass http://inventree-dev-server:8000; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + + proxy_redirect off; + + client_max_body_size 100M; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_request_buffering off; + + } + + # Redirect any requests for static files + location /static/ { + alias /var/www/dev/static/; + autoindex on; + + # Caching settings + expires 30d; + add_header Pragma public; + add_header Cache-Control "public"; + } + + # Redirect any requests for media files + location /media/ { + alias /var/www/dev/media/; + + # Media files require user authentication + auth_request /auth; + } + + # Use the 'user' API endpoint for auth + location /auth { + internal; + + proxy_pass http://inventree-dev-server:8000/auth/; + + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + } + +} diff --git a/requirements.txt b/requirements.txt index d6405bb7bc..b707e9821f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Please keep this list sorted -Django==3.2.10 # Django package +Django==3.2.11 # Django package certifi # Certifi is (most likely) installed through one of the requirements above coreapi==2.3.0 # API documentation coverage==5.3 # Unit test coverage @@ -35,7 +35,7 @@ importlib_metadata # Backport for importlib.metadata inventree # Install the latest version of the InvenTree API python library markdown==3.3.4 # Force particular version of markdown pep8-naming==0.11.1 # PEP naming convention extension -pillow==8.3.2 # Image manipulation +pillow==9.0.0 # Image manipulation py-moneyed==0.8.0 # Specific version requirement for py-moneyed pygments==2.7.4 # Syntax highlighting python-barcode[images]==0.13.1 # Barcode generator