diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index b905e86795..3cd5aa74f7 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -21,28 +21,15 @@ class AuthRequiredMiddleware(object): assert hasattr(request, 'user') - response = self.get_response(request) + # API requests are handled by the DRF library + if request.path_info.startswith('/api/'): + return self.get_response(request) if not request.user.is_authenticated: """ Normally, a web-based session would use csrftoken based authentication. - However when running an external application (e.g. the InvenTree app), - we wish to use token-based auth to grab media files. - - So, we will allow token-based authentication but ONLY for the /media/ directory. - - What problem is this solving? - - The InvenTree mobile app does not use csrf token auth - - Token auth is used by the Django REST framework, but that is under the /api/ endpoint - - Media files (e.g. Part images) are required to be served to the app - - We do not want to make /media/ files accessible without login! - - There is PROBABLY a better way of going about this? - a) Allow token-based authentication against a user? - b) Serve /media/ files in a duplicate location e.g. /api/media/ ? - c) Is there a "standard" way of solving this problem? - - My [google|stackoverflow]-fu has failed me. So this hack has been created. + However when running an external application (e.g. the InvenTree app or Python library), + we must validate the user token manually. """ authorized = False @@ -56,20 +43,23 @@ class AuthRequiredMiddleware(object): elif request.path_info.startswith('/accounts/'): authorized = True - elif 'Authorization' in request.headers.keys(): - auth = request.headers['Authorization'].strip() + elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys(): + auth = request.headers.get('Authorization', request.headers.get('authorization')).strip() - if auth.startswith('Token') and len(auth.split()) == 2: - token = auth.split()[1] + if auth.lower().startswith('token') and len(auth.split()) == 2: + token_key = auth.split()[1] # Does the provided token match a valid user? - if Token.objects.filter(key=token).exists(): + try: + token = Token.objects.get(key=token_key) - allowed = ['/api/', '/media/'] + # Provide the user information to the request + request.user = token.user + authorized = True - # Only allow token-auth for /media/ or /static/ dirs! - if any([request.path_info.startswith(a) for a in allowed]): - authorized = True + except Token.DoesNotExist: + logger.warning(f"Access denied for unknown token {token_key}") + pass # No authorization was found for the request if not authorized: @@ -92,8 +82,7 @@ class AuthRequiredMiddleware(object): return redirect('%s?next=%s' % (reverse_lazy('login'), request.path)) - # Code to be executed for each request/response after - # the view is called. + response = self.get_response(request) return response diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index cab87ee64b..24631dc9e5 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -6,7 +6,8 @@ import json import requests import logging -from datetime import datetime, timedelta +from datetime import timedelta +from django.utils import timezone from django.core.exceptions import AppRegistryNotReady from django.db.utils import OperationalError, ProgrammingError @@ -51,11 +52,14 @@ def schedule_task(taskname, **kwargs): pass -def offload_task(taskname, *args, **kwargs): +def offload_task(taskname, force_sync=False, *args, **kwargs): """ - Create an AsyncTask. - This is different to a 'scheduled' task, - in that it only runs once! + Create an AsyncTask if workers are running. + This is different to a 'scheduled' task, + in that it only runs once! + + If workers are not running or force_sync flag + is set then the task is ran synchronously. """ try: @@ -63,10 +67,48 @@ def offload_task(taskname, *args, **kwargs): except (AppRegistryNotReady): logger.warning("Could not offload task - app registry not ready") return + import importlib + from InvenTree.status import is_worker_running - task = AsyncTask(taskname, *args, **kwargs) + if is_worker_running() and not force_sync: + # Running as asynchronous task + try: + task = AsyncTask(taskname, *args, **kwargs) + task.run() + except ImportError: + logger.warning(f"WARNING: '{taskname}' not started - Function not found") + else: + # Split path + try: + app, mod, func = taskname.split('.') + app_mod = app + '.' + mod + except ValueError: + logger.warning(f"WARNING: '{taskname}' not started - Malformed function path") + return - task.run() + # Import module from app + try: + _mod = importlib.import_module(app_mod) + except ModuleNotFoundError: + logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'") + return + + # Retrieve function + try: + _func = getattr(_mod, func) + except AttributeError: + # getattr does not work for local import + _func = None + + try: + if not _func: + _func = eval(func) + except NameError: + logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'") + return + + # Workers are not running: run it as synchronous task + _func() def heartbeat(): @@ -84,7 +126,7 @@ def heartbeat(): except AppRegistryNotReady: return - threshold = datetime.now() - timedelta(minutes=30) + threshold = timezone.now() - timedelta(minutes=30) # Delete heartbeat results more than half an hour old, # otherwise they just create extra noise @@ -108,7 +150,7 @@ def delete_successful_tasks(): logger.info("Could not perform 'delete_successful_tasks' - App registry not ready") return - threshold = datetime.now() - timedelta(days=30) + threshold = timezone.now() - timedelta(days=30) results = Success.objects.filter( started__lte=threshold diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 69deb8fd97..0528c6c694 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -31,8 +31,6 @@ from stock.models import StockLocation, StockItem from common.models import InvenTreeSetting, ColorTheme from users.models import check_user_role, RuleSet -import InvenTree.tasks - from .forms import DeleteForm, EditUserForm, SetPasswordForm from .forms import SettingCategorySelectForm from .helpers import str2bool @@ -827,8 +825,13 @@ class CurrencyRefreshView(RedirectView): On a POST request we will attempt to refresh the exchange rates """ - # Will block for a little bit - InvenTree.tasks.update_exchange_rates() + from InvenTree.tasks import offload_task + + # Define associated task from InvenTree.tasks list of methods + taskname = 'InvenTree.tasks.update_exchange_rates' + + # Run it + offload_task(taskname, force_sync=True) return redirect(reverse_lazy('settings')) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 3b731381b8..2531781631 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -9,6 +9,8 @@ import os from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator +from django.core.exceptions import ValidationError + from django.db import models from django.db.models import Sum, Q, UniqueConstraint @@ -473,12 +475,32 @@ class SupplierPart(models.Model): def get_absolute_url(self): return reverse('supplier-part-detail', kwargs={'pk': self.id}) + def api_instance_filters(self): + + return { + 'manufacturer_part': { + 'part': self.part.pk + } + } + class Meta: unique_together = ('part', 'supplier', 'SKU') # This model was moved from the 'Part' app db_table = 'part_supplierpart' + def clean(self): + + super().clean() + + # Ensure that the linked manufacturer_part points to the same part! + if self.manufacturer_part and self.part: + + if not self.manufacturer_part.part == self.part: + raise ValidationError({ + 'manufacturer_part': _("Linked manufacturer part must reference the same base part"), + }) + part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='supplier_parts', verbose_name=_('Base Part'), diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html index d1f325aaee..3bd51c1855 100644 --- a/InvenTree/part/templates/part/bom_upload/match_fields.html +++ b/InvenTree/part/templates/part/bom_upload/match_fields.html @@ -5,7 +5,7 @@ {% block form_alert %} {% if missing_columns and missing_columns|length > 0 %} -
{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} + {% if description %}- {{ description }}{% endif %}
-{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} -{% if description %}- {{ description }}{% endif %}
+ -{% endblock form_buttons_bottom %} + {% block form_buttons_bottom %} + {% if wizard.steps.prev %} + + {% endif %} + + + {% endblock form_buttons_bottom %} -{% endblock %} \ No newline at end of file + {% endblock details %} +@@ -305,6 +306,11 @@ {% block js_ready %} {{ block.super }} + enableNavbar({ + label: 'part', + toggleId: '#part-menu-toggle', + }); + {% if part.image %} $('#part-thumb').click(function() { showModalImage('{{ part.image.url }}'); diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index d380ea3369..19295d1198 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -3,6 +3,7 @@ {% load static %} {% load inventree_extras %} {% load i18n %} +{% load l10n %} {% load markdownify %} {% block menubar %} @@ -152,7 +153,7 @@ { stock_item: {{ item.pk }}, part: {{ item.part.pk }}, - quantity: {{ item.quantity }}, + quantity: {{ item.quantity|unlocalize }}, } ); @@ -395,4 +396,4 @@ url: "{% url 'api-stock-tracking-list' %}", }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/templates/js/dynamic/nav.js b/InvenTree/templates/js/dynamic/nav.js index aff3435b21..601c0f4dcf 100644 --- a/InvenTree/templates/js/dynamic/nav.js +++ b/InvenTree/templates/js/dynamic/nav.js @@ -72,7 +72,12 @@ function activatePanel(panelName, options={}) { // Display the panel $(panel).addClass('panel-visible'); - $(panel).fadeIn(100); + + // Load the data + $(panel).trigger('fadeInStarted'); + + $(panel).fadeIn(100, function() { + }); // Un-select all selectors $('.list-group-item').removeClass('active'); @@ -81,4 +86,23 @@ function activatePanel(panelName, options={}) { var select = `#select-${panelName}`; $(select).parent('.list-group-item').addClass('active'); +} + + +function onPanelLoad(panel, callback) { + // One-time callback when a panel is first displayed + // Used to implement lazy-loading, rather than firing + // multiple AJAX queries when the page is first loaded. + + var panelId = `#panel-${panel}`; + + $(panelId).on('fadeInStarted', function(e) { + + // Trigger the callback + callback(); + + // Turn off the event + $(panelId).off('fadeInStarted'); + + }); } \ No newline at end of file diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index 9c73fcc111..4ecea2c595 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -54,8 +54,12 @@ function editManufacturerPart(part, options={}) { var url = `/api/company/part/manufacturer/${part}/`; + var fields = manufacturerPartFields(); + + fields.part.hidden = true; + constructForm(url, { - fields: manufacturerPartFields(), + fields: fields, title: '{% trans "Edit Manufacturer Part" %}', onSuccess: options.onSuccess }); @@ -157,8 +161,13 @@ function createSupplierPart(options={}) { function editSupplierPart(part, options={}) { + var fields = supplierPartFields(); + + // Hide the "part" field + fields.part.hidden = true; + constructForm(`/api/company/part/${part}/`, { - fields: supplierPartFields(), + fields: fields, title: '{% trans "Edit Supplier Part" %}', onSuccess: options.onSuccess }); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 36b3bea03a..f489b45948 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -263,11 +263,13 @@ function adjustStock(action, items, options={}) { required: true, api_url: `/api/stock/location/`, model: 'stocklocation', + name: 'location', }, notes: { label: '{% trans "Notes" %}', help_text: '{% trans "Stock transaction notes" %}', type: 'string', + name: 'notes', } }; @@ -665,6 +667,7 @@ function loadStockTable(table, options) { // List of user-params which override the default filters options.params['location_detail'] = true; + options.params['part_detail'] = true; var params = options.params || {}; @@ -1114,11 +1117,11 @@ function loadStockTable(table, options) { function stockAdjustment(action) { - var items = $("#stock-table").bootstrapTable("getSelections"); + var items = $(table).bootstrapTable("getSelections"); adjustStock(action, items, { onSuccess: function() { - $('#stock-table').bootstrapTable('refresh'); + $(table).bootstrapTable('refresh'); } }); } @@ -1126,7 +1129,7 @@ function loadStockTable(table, options) { // Automatically link button callbacks $('#multi-item-print-label').click(function() { - var selections = $('#stock-table').bootstrapTable('getSelections'); + var selections = $(table).bootstrapTable('getSelections'); var items = []; @@ -1138,7 +1141,7 @@ function loadStockTable(table, options) { }); $('#multi-item-print-test-report').click(function() { - var selections = $('#stock-table').bootstrapTable('getSelections'); + var selections = $(table).bootstrapTable('getSelections'); var items = []; @@ -1151,7 +1154,7 @@ function loadStockTable(table, options) { if (global_settings.BARCODE_ENABLE) { $('#multi-item-barcode-scan-into-location').click(function() { - var selections = $('#stock-table').bootstrapTable('getSelections'); + var selections = $(table).bootstrapTable('getSelections'); var items = []; @@ -1180,7 +1183,7 @@ function loadStockTable(table, options) { }); $("#multi-item-order").click(function() { - var selections = $("#stock-table").bootstrapTable("getSelections"); + var selections = $(table).bootstrapTable("getSelections"); var stock = []; @@ -1197,7 +1200,7 @@ function loadStockTable(table, options) { $("#multi-item-set-status").click(function() { // Select and set the STATUS field for selected stock items - var selections = $("#stock-table").bootstrapTable('getSelections'); + var selections = $(table).bootstrapTable('getSelections'); // Select stock status var modal = '#modal-form'; @@ -1277,13 +1280,13 @@ function loadStockTable(table, options) { }); $.when.apply($, requests).done(function() { - $("#stock-table").bootstrapTable('refresh'); + $(table).bootstrapTable('refresh'); }); }) }); $("#multi-item-delete").click(function() { - var selections = $("#stock-table").bootstrapTable("getSelections"); + var selections = $(table).bootstrapTable("getSelections"); var stock = []; diff --git a/docker/nginx.conf b/docker/nginx.conf index 270378735e..271f65a89d 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -10,7 +10,7 @@ server { proxy_pass http://inventree-server:8000; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_redirect off; @@ -54,4 +54,4 @@ server { proxy_set_header X-Original-URI $request_uri; } -} \ No newline at end of file +}