Merged master

This commit is contained in:
eeintech 2021-08-11 16:16:20 -04:00
commit a2590f1a3b
52 changed files with 36131 additions and 32661 deletions

View File

@ -13,6 +13,9 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Check Release tag
run: |
python3 ci/check_version_number.py ${{ github.event.release.tag_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

View File

@ -344,13 +344,15 @@ def GetExportFormats():
]
def DownloadFile(data, filename, content_type='application/text'):
""" Create a dynamic file for the user to download.
def DownloadFile(data, filename, content_type='application/text', inline=False):
"""
Create a dynamic file for the user to download.
Args:
data: Raw file data (string or bytes)
filename: Filename for the file download
content_type: Content type for the download
inline: Download "inline" or as attachment? (Default = attachment)
Return:
A StreamingHttpResponse object wrapping the supplied data
@ -365,7 +367,10 @@ def DownloadFile(data, filename, content_type='application/text'):
response = StreamingHttpResponse(wrapper, content_type=content_type)
response['Content-Length'] = len(data)
response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename)
disposition = "inline" if inline else "attachment"
response['Content-Disposition'] = f'{disposition}; filename={filename}'
return response

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

@ -10,11 +10,14 @@ import common.models
INVENTREE_SW_VERSION = "0.5.0 pre"
INVENTREE_API_VERSION = 8
INVENTREE_API_VERSION = 9
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v9 -> 2021-08-09
- Adds "price_string" to part pricing serializers
v8 -> 2021-07-19
- Refactors the API interface for SupplierPart and ManufacturerPart models
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint

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

@ -926,6 +926,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
"LABEL_INLINE": {
'name': _('Inline label display'),
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
'default': True,
'validator': bool,
},
"REPORT_INLINE": {
'name': _('Inline report display'),
'description': _('Display PDF reports in the browser, instead of downloading as a file'),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in search preview window'),
@ -965,7 +979,10 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
@classmethod
def get_filters(cls, key, **kwargs):
return {'key__iexact': key, 'user__id': kwargs['user'].id}
return {
'key__iexact': key,
'user__id': kwargs['user'].id
}
class PriceBreak(models.Model):

View File

@ -10,6 +10,7 @@ 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.utils import IntegrityError
from django.db.models import Sum, Q, UniqueConstraint
@ -475,6 +476,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"),
})
def save(self, *args, **kwargs):
""" Overriding save method to process the linked ManufacturerPart
"""
@ -526,12 +553,6 @@ class SupplierPart(models.Model):
super().save(*args, **kwargs)
class Meta:
unique_together = ('part', 'supplier', 'SKU')
# This model was moved from the 'Part' app
db_table = 'part_supplierpart'
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='supplier_parts',
verbose_name=_('Base Part'),

View File

@ -109,10 +109,13 @@ class LabelPrintMixin:
else:
pdf = outputs[0].get_document().write_pdf()
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user)
return InvenTree.helpers.DownloadFile(
pdf,
label_name,
content_type='application/pdf'
content_type='application/pdf',
inline=inline
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.4 on 2021-08-07 11:40
from django.db import migrations, models
import part.models
class Migration(migrations.Migration):
dependencies = [
('part', '0070_alter_part_variant_of'),
]
operations = [
migrations.AlterField(
model_name='partparametertemplate',
name='name',
field=models.CharField(help_text='Parameter Name', max_length=100, unique=True, validators=[part.models.validate_template_name], verbose_name='Name'),
),
]

View File

@ -2143,6 +2143,16 @@ class PartTestTemplate(models.Model):
)
def validate_template_name(name):
"""
Prevent illegal characters in "name" field for PartParameterTemplate
"""
for c in "!@#$%^&*()<>{}[].,?/\|~`_+-=\'\"":
if c in str(name):
raise ValidationError(_(f"Illegal character in template name ({c})"))
class PartParameterTemplate(models.Model):
"""
A PartParameterTemplate provides a template for key:value pairs for extra
@ -2181,7 +2191,15 @@ class PartParameterTemplate(models.Model):
except PartParameterTemplate.DoesNotExist:
pass
name = models.CharField(max_length=100, verbose_name=_('Name'), help_text=_('Parameter Name'), unique=True)
name = models.CharField(
max_length=100,
verbose_name=_('Name'),
help_text=_('Parameter Name'),
unique=True,
validators=[
validate_template_name,
]
)
units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True)

View File

@ -15,7 +15,8 @@ from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer)
InvenTreeModelSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
@ -102,7 +103,12 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
price = serializers.CharField()
price = InvenTreeMoneySerializer(
max_digits=19, decimal_places=4,
allow_null=True
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
model = PartSellPriceBreak
@ -111,6 +117,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_string',
]
@ -121,7 +128,12 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
price = serializers.CharField()
price = InvenTreeMoneySerializer(
max_digits=19, decimal_places=4,
allow_null=True
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
model = PartInternalPriceBreak
@ -130,6 +142,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_string',
]

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

@ -204,6 +204,7 @@ def settings_value(key, *args, **kwargs):
if 'user' in kwargs:
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return InvenTreeSetting.get_setting(key)

View File

@ -254,10 +254,13 @@ class ReportPrintMixin:
else:
pdf = outputs[0].get_document().write_pdf()
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user)
return InvenTree.helpers.DownloadFile(
pdf,
report_name,
content_type='application/pdf'
content_type='application/pdf',
inline=inline,
)

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

@ -30,6 +30,18 @@
</a>
</li>
<li class='list-group-item' title='{% trans "Labels" %}'>
<a href='#' class='nav-toggle' id='select-user-labels'>
<span class='fas fa-tag'></span> {% trans "Labels" %}
</a>
</li>
<li class='list-group-item' title='{% trans "Reports" %}'>
<a href='#' class='nav-toggle' id='select-user-reports'>
<span class='fas fa-file-pdf'></span> {% trans "Reports" %}
</a>
</li>
<!--
<li class='list-group-item' title='{% trans "Settings" %}'>
<a href='#' class='nav-toggle' id='select-user-settings'>

View File

@ -68,80 +68,3 @@
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'name',
title: 'Name',
sortable: 'true',
},
{
field: 'units',
title: 'Units',
sortable: 'true',
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
launchModalForm("{% url 'part-param-template-create' %}", {
success: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var url = "/part/parameter/template/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var url = "/part/parameter/template/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
{% endblock %}

View File

@ -18,6 +18,8 @@
{% include "InvenTree/settings/user_settings.html" %}
{% include "InvenTree/settings/user_homepage.html" %}
{% include "InvenTree/settings/user_search.html" %}
{% include "InvenTree/settings/user_labels.html" %}
{% include "InvenTree/settings/user_reports.html" %}
{% if user.is_staff %}
@ -241,6 +243,79 @@ $("#cat-param-table").on('click', '.template-delete', function() {
});
});
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'name',
title: 'Name',
sortable: 'true',
},
{
field: 'units',
title: 'Units',
sortable: 'true',
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
launchModalForm("{% url 'part-param-template-create' %}", {
success: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var url = "/part/parameter/template/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var url = "/part/parameter/template/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
enableNavbar({
label: 'settings',
toggleId: '#item-menu-toggle',

View File

@ -0,0 +1,23 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}user-labels{% endblock %}
{% block heading %}
{% trans "Label Settings" %}
{% endblock %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="LABEL_INLINE" icon='fa-tag' user_setting=True %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}user-reports{% endblock %}
{% block heading %}
{% trans "Report Settings" %}
{% endblock %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="REPORT_INLINE" icon='fa-file-pdf' user_setting=True %}
</tbody>
</table>
</div>
{% 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

@ -30,6 +30,17 @@ function createManufacturerPart(options={}) {
fields.manufacturer.value = options.manufacturer;
}
fields.manufacturer.secondary = {
title: '{% trans "Add Manufacturer" %}',
fields: function(data) {
var company_fields = companyFormFields();
company_fields.is_manufacturer.value = true;
return company_fields;
}
}
constructForm('{% url "api-manufacturer-part-list" %}', {
fields: fields,
method: 'POST',
@ -43,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
});
@ -72,7 +87,7 @@ function supplierPartFields() {
filters: {
part_detail: true,
manufacturer_detail: true,
}
},
},
description: {},
link: {
@ -108,6 +123,33 @@ function createSupplierPart(options={}) {
fields.manufacturer_part.value = options.manufacturer_part;
}
// Add a secondary modal for the supplier
fields.supplier.secondary = {
title: '{% trans "Add Supplier" %}',
fields: function(data) {
var company_fields = companyFormFields();
company_fields.is_supplier.value = true;
return company_fields;
}
};
// Add a secondary modal for the manufacturer part
fields.manufacturer_part.secondary = {
title: '{% trans "Add Manufacturer Part" %}',
fields: function(data) {
var mp_fields = manufacturerPartFields();
if (data.part) {
mp_fields.part.value = data.part;
mp_fields.part.hidden = true;
}
return mp_fields;
}
};
constructForm('{% url "api-supplier-part-list" %}', {
fields: fields,
method: 'POST',
@ -119,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

@ -564,6 +564,30 @@ function insertConfirmButton(options) {
}
/*
* Extract all specified form values as a single object
*/
function extractFormData(fields, options) {
var data = {};
for (var idx = 0; idx < options.field_names.length; idx++) {
var name = options.field_names[idx];
var field = fields[name] || null;
if (!field) continue;
if (field.type == 'candy') continue;
data[name] = getFormFieldValue(name, field, options);
}
return data;
}
/*
* Submit form data to the server.
*
@ -950,10 +974,10 @@ function initializeRelatedFields(fields, options) {
switch (field.type) {
case 'related field':
initializeRelatedField(name, field, options);
initializeRelatedField(field, fields, options);
break;
case 'choice':
initializeChoiceField(name, field, options);
initializeChoiceField(field, fields, options);
break;
}
}
@ -968,7 +992,9 @@ function initializeRelatedFields(fields, options) {
* - field: The field data object
* - options: The options object provided by the client
*/
function addSecondaryModal(name, field, options) {
function addSecondaryModal(field, fields, options) {
var name = field.name;
var secondary = field.secondary;
@ -981,22 +1007,42 @@ function addSecondaryModal(name, field, options) {
$(options.modal).find(`label[for="id_${name}"]`).append(html);
// TODO: Launch a callback
// Callback function when the secondary button is pressed
$(options.modal).find(`#btn-new-${name}`).click(function() {
if (secondary.callback) {
// A "custom" callback can be specified for the button
secondary.callback(field, options);
} else if (secondary.api_url) {
// By default, a new modal form is created, with the parameters specified
// The parameters match the "normal" form creation parameters
// Determine the API query URL
var url = secondary.api_url || field.api_url;
secondary.onSuccess = function(data, opts) {
setRelatedFieldData(name, data, options);
};
// If the "fields" attribute is a function, call it with data
if (secondary.fields instanceof Function) {
constructForm(secondary.api_url, secondary);
// Extract form values at time of button press
var data = extractFormData(fields, options)
secondary.fields = secondary.fields(data);
}
// If no onSuccess function is defined, provide a default one
if (!secondary.onSuccess) {
secondary.onSuccess = function(data, opts) {
// Force refresh from the API, to get full detail
inventreeGet(`${url}${data.pk}/`, {}, {
success: function(responseData) {
setRelatedFieldData(name, responseData, options);
}
});
};
}
// Method should be "POST" for creation
secondary.method = secondary.method || 'POST';
constructForm(
url,
secondary
);
});
}
@ -1010,7 +1056,9 @@ function addSecondaryModal(name, field, options) {
* - field: Field definition from the OPTIONS request
* - options: Original options object provided by the client
*/
function initializeRelatedField(name, field, options) {
function initializeRelatedField(field, fields, options) {
var name = field.name;
if (!field.api_url) {
// TODO: Provide manual api_url option?
@ -1023,7 +1071,7 @@ function initializeRelatedField(name, field, options) {
// Add a button to launch a 'secondary' modal
if (field.secondary != null) {
addSecondaryModal(name, field, options);
addSecondaryModal(field, fields, options);
}
// TODO: Add 'placeholder' support for entry select2 fields
@ -1192,7 +1240,9 @@ function setRelatedFieldData(name, data, options) {
}
function initializeChoiceField(name, field, options) {
function initializeChoiceField(field, fields, options) {
var name = field.name;
var select = $(options.modal).find(`#id_${name}`);

View File

@ -13,6 +13,16 @@ function createSalesOrder(options={}) {
},
customer: {
value: options.customer,
secondary: {
title: '{% trans "Add Customer" %}',
fields: function(data) {
var fields = companyFormFields();
fields.is_customer.value = true;
return fields;
}
}
},
customer_reference: {},
description: {},
@ -44,6 +54,16 @@ function createPurchaseOrder(options={}) {
},
supplier: {
value: options.supplier,
secondary: {
title: '{% trans "Add Supplier" %}',
fields: function(data) {
var fields = companyFormFields();
fields.is_supplier.value = true;
return fields;
}
}
},
supplier_reference: {},
description: {},

View File

@ -17,7 +17,16 @@ function yesNoLabel(value) {
function partFields(options={}) {
var fields = {
category: {},
category: {
secondary: {
title: '{% trans "Add Part Category" %}',
fields: function(data) {
var fields = categoryFields();
return fields;
}
}
},
name: {},
IPN: {},
revision: {},
@ -30,7 +39,8 @@ function partFields(options={}) {
link: {
icon: 'fa-link',
},
default_location: {},
default_location: {
},
default_supplier: {},
default_expiry: {
icon: 'fa-calendar-alt',
@ -1096,6 +1106,7 @@ function loadPriceBreakTable(table, options) {
formatNoMatches: function() {
return `{% trans "No ${human_name} information found" %}`;
},
queryParams: {part: options.part},
url: options.url,
onLoadSuccess: function(tableData) {
if (linkedGraph) {
@ -1104,7 +1115,7 @@ function loadPriceBreakTable(table, options) {
// split up for graph definition
var graphLabels = Array.from(tableData, x => x.quantity);
var graphData = Array.from(tableData, x => parseFloat(x.price));
var graphData = Array.from(tableData, x => x.price);
// destroy chart if exists
if (chart){
@ -1191,6 +1202,7 @@ function initPriceBreakSet(table, options) {
human_name: pb_human_name,
url: pb_url,
linkedGraph: linkedGraph,
part: part_id,
}
);

View File

@ -2,6 +2,18 @@
{% load inventree_extras %}
{% load status_codes %}
function locationFields() {
return {
parent: {
help_text: '{% trans "Parent stock location" %}',
},
name: {},
description: {},
};
}
/* Stock API functions
* Requires api.js to be loaded first
*/
@ -251,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',
}
};
@ -653,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 || {};
@ -1102,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');
}
});
}
@ -1114,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 = [];
@ -1126,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 = [];
@ -1139,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 = [];
@ -1168,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 = [];
@ -1185,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';
@ -1265,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

@ -24,7 +24,7 @@ However, powerful business logic works in the background to ensure that stock tr
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.
# Companion App
# Mobile App
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.

View File

@ -0,0 +1,38 @@
"""
On release, ensure that the release tag matches the InvenTree version number!
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import re
import os
import argparse
if __name__ == '__main__':
here = os.path.abspath(os.path.dirname(__file__))
version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py')
with open(version_file, 'r') as f:
results = re.findall(r'INVENTREE_SW_VERSION = "(.*)"', f.read())
if not len(results) == 1:
print(f"Could not find INVENTREE_SW_VERSION in {version_file}")
sys.exit(1)
version = results[0]
parser = argparse.ArgumentParser()
parser.add_argument('tag', help='Version tag', action='store')
args = parser.parse_args()
if not args.tag == version:
print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
sys.exit(1)
sys.exit(0)

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