Merge remote-tracking branch 'inventree/master' into 0.4.x

This commit is contained in:
Oliver 2021-08-11 00:29:36 +10:00
commit aea43924ae
13 changed files with 717 additions and 559 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
<div class='alert alert-danger alert-block' style='margin-top:12px;' role='alert'>
{% trans "Missing selections for the following required columns" %}:
<br>
<ul>

View File

@ -1,49 +1,76 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block heading %}
{% trans "Upload BOM File" %}
{% block menubar %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='part-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item' title='{% trans "Return To BOM" %}'>
<a href='{% url "part-detail" part.id %}' id='select-upload-file' class='nav-toggle'>
<span class='fas fa-undo side-icon'></span>
{% trans "Return To BOM" %}
</a>
</li>
</ul>
{% endblock %}
{% block page_content %}
<h4>{% trans "Upload Bill of Materials" %}</h4>
<div class='panel panel-default panel-inventree' id='panel-upload-file'>
<div class='panel-heading'>
{% block heading %}
<h4>{% trans "Upload Bill of Materials" %}</h4>
{{ wizard.form.media }}
{% endblock %}
</div>
<div class='panel-content'>
{% block details %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<b>{% trans "Requirements for BOM upload" %}:</b>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <b><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></b></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<b>{% trans "Requirements for BOM upload" %}:</b>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <b><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></b></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% endblock %}
{% endblock details %}
</div>
{% endblock page_content %}
{% block js_ready %}
{{ block.super }}
{% endblock js_ready %}

File diff suppressed because it is too large Load Diff

View File

@ -213,6 +213,7 @@
<p>
<!-- Details show/hide button -->
<button id="toggle-part-details" class="btn btn-primary" data-toggle="collapse" data-target="#collapsible-part-details" value="show">
<span class="fas fa-chevron-down"></span> {% trans "Show Part Details" %}
</button>
</p>
@ -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 }}');

View File

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

View File

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

View File

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

View File

@ -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 = [];

View File

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