[FR] Two-Factor Authentication

Fixes #2201
This commit is contained in:
Matthias 2021-11-19 23:48:12 +01:00
commit eee9047818
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
69 changed files with 20388 additions and 2160 deletions

View File

@ -27,6 +27,7 @@ jobs:
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_CACHE_HOST: localhost
services:
postgres:
@ -37,6 +38,11 @@ jobs:
ports:
- 5432:5432
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Checkout Code
uses: actions/checkout@v2
@ -49,6 +55,7 @@ jobs:
sudo apt-get install libpq-dev
pip3 install invoke
pip3 install psycopg2
pip3 install django-redis>=5.0.0
invoke install
- name: Run Tests
run: invoke test

View File

@ -118,20 +118,31 @@ class InvenTreeMetadata(SimpleMetadata):
# Iterate through simple fields
for name, field in model_fields.fields.items():
if field.has_default() and name in serializer_info.keys():
if name in serializer_info.keys():
default = field.default
if field.has_default():
if callable(default):
try:
default = default()
except:
continue
default = field.default
serializer_info[name]['default'] = default
if callable(default):
try:
default = default()
except:
continue
elif name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
serializer_info[name]['default'] = default
elif name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
# Attributes to copy from the model to the field (if they don't exist)
attributes = ['help_text']
for attr in attributes:
if attr not in serializer_info[name]:
if hasattr(field, attr):
serializer_info[name][attr] = getattr(field, attr)
# Iterate through relations
for name, relation in model_fields.relations.items():

View File

@ -296,3 +296,17 @@ class InvenTreeImageSerializerField(serializers.ImageField):
return None
return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeDecimalField(serializers.FloatField):
"""
Custom serializer for decimal fields. Solves the following issues:
- The normal DRF DecimalField renders values with trailing zeros
- Using a FloatField can result in rounding issues: https://code.djangoproject.com/ticket/30290
"""
def to_internal_value(self, data):
# Convert the value to a string, and then a decimal
return Decimal(str(data))

View File

@ -15,6 +15,7 @@ import logging
import os
import random
import socket
import string
import shutil
import sys
@ -91,6 +92,12 @@ DEBUG = _is_true(get_setting(
CONFIG.get('debug', True)
))
# Determine if we are running in "demo mode"
DEMO_MODE = _is_true(get_setting(
'INVENTREE_DEMO',
CONFIG.get('demo', False)
))
DOCKER = _is_true(get_setting(
'INVENTREE_DOCKER',
False
@ -233,7 +240,10 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
MEDIA_URL = '/media/'
if DEBUG:
logger.info("InvenTree running in DEBUG mode")
logger.info("InvenTree running with DEBUG enabled")
if DEMO_MODE:
logger.warning("InvenTree running in DEMO mode")
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
@ -370,30 +380,6 @@ REST_FRAMEWORK = {
WSGI_APPLICATION = 'InvenTree.wsgi.application'
background_workers = os.environ.get('INVENTREE_BACKGROUND_WORKERS', None)
if background_workers is not None:
try:
background_workers = int(background_workers)
except ValueError:
background_workers = None
if background_workers is None:
# Sensible default?
background_workers = 4
# django-q configuration
Q_CLUSTER = {
'name': 'InvenTree',
'workers': background_workers,
'timeout': 90,
'retry': 120,
'queue_limit': 50,
'bulk': 10,
'orm': 'default',
'sync': False,
}
"""
Configure the database backend based on the user-specified values.
@ -571,12 +557,84 @@ DATABASES = {
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
_cache_config = CONFIG.get("cache", {})
_cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST"))
_cache_port = _cache_config.get(
"port", os.getenv("INVENTREE_CACHE_PORT", "6379")
)
if _cache_host:
# 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
# irreplacable. Django Q Cluster will just try again later.
_cache_options = {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")),
"SOCKET_TIMEOUT": int(os.getenv("CACHE_SOCKET_TIMEOUT", "2")),
"CONNECTION_POOL_KWARGS": {
"socket_keepalive": _is_true(
os.getenv("CACHE_TCP_KEEPALIVE", "1")
),
"socket_keepalive_options": {
socket.TCP_KEEPCNT: int(
os.getenv("CACHE_KEEPALIVES_COUNT", "5")
),
socket.TCP_KEEPIDLE: int(
os.getenv("CACHE_KEEPALIVES_IDLE", "1")
),
socket.TCP_KEEPINTVL: int(
os.getenv("CACHE_KEEPALIVES_INTERVAL", "1")
),
socket.TCP_USER_TIMEOUT: int(
os.getenv("CACHE_TCP_USER_TIMEOUT", "1000")
),
},
},
}
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": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": f"redis://{_cache_host}:{_cache_port}/1",
"OPTIONS": _cache_options,
},
}
else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
}
try:
# 4 background workers seems like a sensible default
background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4))
except ValueError:
background_workers = 4
# django-q configuration
Q_CLUSTER = {
'name': 'InvenTree',
'workers': background_workers,
'timeout': 90,
'retry': 120,
'queue_limit': 50,
'bulk': 10,
'orm': 'default',
'sync': False,
}
if _cache_host:
# If using external redis cache, make the cache the broker for Django Q
# as well
Q_CLUSTER["django_redis"] = "worker"
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
@ -615,6 +673,7 @@ LANGUAGES = [
('el', _('Greek')),
('en', _('English')),
('es', _('Spanish')),
('es-mx', _('Spanish (Mexican')),
('fr', _('French')),
('he', _('Hebrew')),
('it', _('Italian')),
@ -623,6 +682,7 @@ LANGUAGES = [
('nl', _('Dutch')),
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portugese')),
('ru', _('Russian')),
('sv', _('Swedish')),
('th', _('Thai')),

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -208,7 +208,7 @@ function inventreeDocReady() {
});
// Callback for "admin view" button
$('#admin-button').click(function() {
$('#admin-button, .admin-button').click(function() {
var url = $(this).attr('url');
location.href = url;

View File

@ -1,13 +0,0 @@
from rest_framework.views import exception_handler
def api_exception_handler(exc, context):
response = exception_handler(exc, context)
# Now add the HTTP status code to the response.
if response is not None:
data = {'error': response.data}
response.data = data
return response

View File

@ -12,11 +12,16 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 17
INVENTREE_API_VERSION = 18
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v18 -> 2021-11-15
- Adds the ability to filter BomItem API by "uses" field
- This returns a list of all BomItems which "use" the specified part
- Includes inherited BomItem objects
v17 -> 2021-11-09
- Adds API endpoints for GLOBAL and USER settings objects
- Ref: https://github.com/inventree/InvenTree/pull/2275

View File

@ -18,8 +18,9 @@ from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
from InvenTree.status_codes import StockStatus
import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus
from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
@ -41,7 +42,7 @@ class BuildSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
overdue = serializers.BooleanField(required=False, read_only=True)
@ -473,7 +474,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
def __init__(self, *args, **kwargs):

View File

@ -34,6 +34,7 @@ src="{% static 'img/blank_image.png' %}"
{% include "admin_button.html" with url=url %}
{% endif %}
<!-- Printing options -->
{% if report_enabled %}
<div class='btn-group'>
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span>
@ -42,6 +43,7 @@ src="{% static 'img/blank_image.png' %}"
<li><a class='dropdown-item' href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li>
</ul>
</div>
{% endif %}
<!-- Build actions -->
{% if roles.build.change %}
<div class='btn-group'>
@ -224,9 +226,11 @@ src="{% static 'img/blank_image.png' %}"
{% endif %}
});
{% if report_enabled %}
$('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]);
});
{% endif %}
$("#build-delete").on('click', function() {
launchModalForm(

View File

@ -27,6 +27,7 @@
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% if report_enabled %}
<div class='btn-group' role='group'>
<!-- Print actions -->
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'>
@ -38,6 +39,7 @@
</a></li>
</ul>
</div>
{% endif %}
<!-- Buttons to switch between list and calendar views -->
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span>
@ -181,6 +183,7 @@ loadBuildTable($("#build-table"), {
url: "{% url 'api-build-list' %}",
});
{% if report_enabled %}
$('#multi-build-print').click(function() {
var rows = $("#build-table").bootstrapTable('getSelections');
@ -192,5 +195,6 @@ $('#multi-build-print').click(function() {
printBuildReports(build_ids);
});
{% endif %}
{% endblock %}

View File

@ -12,11 +12,31 @@ class SettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value')
def get_readonly_fields(self, request, obj=None):
"""
Prevent the 'key' field being edited once the setting is created
"""
if obj:
return ['key']
else:
return []
class UserSettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value', 'user', )
def get_readonly_fields(self, request, obj=None):
"""
Prevent the 'key' field being edited once the setting is created
"""
if obj:
return ['key']
else:
return []
class NotificationEntryAdmin(admin.ModelAdmin):

View File

@ -1,10 +1,30 @@
# -*- coding: utf-8 -*-
import logging
from django.apps import AppConfig
logger = logging.getLogger('inventree')
class CommonConfig(AppConfig):
name = 'common'
def ready(self):
pass
self.clear_restart_flag()
def clear_restart_flag(self):
"""
Clear the SERVER_RESTART_REQUIRED setting
"""
try:
import common.models
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED'):
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
except:
pass

View File

@ -63,13 +63,15 @@ class BaseInvenTreeSetting(models.Model):
Enforce validation and clean before saving
"""
self.key = str(self.key).upper()
self.clean()
self.validate_unique()
super().save()
@classmethod
def allValues(cls, user=None):
def allValues(cls, user=None, exclude_hidden=False):
"""
Return a dict of "all" defined global settings.
@ -94,9 +96,15 @@ class BaseInvenTreeSetting(models.Model):
for key in cls.GLOBAL_SETTINGS.keys():
if key.upper() not in settings:
settings[key.upper()] = cls.get_setting_default(key)
if exclude_hidden:
hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False)
if hidden:
# Remove hidden items
del settings[key.upper()]
for key, value in settings.items():
validator = cls.get_setting_validator(key)
@ -545,6 +553,17 @@ class InvenTreeSetting(BaseInvenTreeSetting):
even if that key does not exist.
"""
def save(self, *args, **kwargs):
"""
When saving a global setting, check to see if it requires a server restart.
If so, set the "SERVER_RESTART_REQUIRED" setting to True
"""
super().save()
if self.requires_restart():
InvenTreeSetting.set_setting('SERVER_REQUIRES_RESTART', True, None)
"""
Dict of all global settings values:
@ -563,6 +582,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
GLOBAL_SETTINGS = {
'SERVER_RESTART_REQUIRED': {
'name': _('Restart required'),
'description': _('A setting has been changed which requires a server restart'),
'default': False,
'validator': bool,
'hidden': True,
},
'INVENTREE_INSTANCE': {
'name': _('InvenTree Instance Name'),
'default': 'InvenTree server',
@ -768,6 +795,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': InvenTree.validators.validate_part_name_format
},
'REPORT_ENABLE': {
'name': _('Enable Reports'),
'description': _('Enable generation of reports'),
'default': False,
'validator': bool,
},
'REPORT_DEBUG_MODE': {
'name': _('Debug Mode'),
'description': _('Generate reports in debug mode (HTML output)'),
@ -935,6 +969,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
return self.__class__.get_setting(self.key)
def requires_restart(self):
"""
Return True if this setting requires a server restart after changing
"""
options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None)
if options:
return options.get('requires_restart', False)
else:
return False
class InvenTreeUserSetting(BaseInvenTreeSetting):
"""
@ -1268,9 +1314,6 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break
class ColorTheme(models.Model):
""" Color Theme Setting """
default_color_theme = ('', _('Default'))
name = models.CharField(max_length=20,
default='',
blank=True)
@ -1290,10 +1333,7 @@ class ColorTheme(models.Model):
# Get color themes choices (CSS sheets)
choices = [(file_name.lower(), _(file_name.replace('-', ' ').title()))
for file_name, file_ext in files_list
if file_ext == '.css' and file_name.lower() != 'default']
# Add default option as empty option
choices.insert(0, cls.default_color_theme)
if file_ext == '.css']
return choices

View File

@ -8,9 +8,10 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeImageSerializerField
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeImageSerializerField
from part.serializers import PartBriefSerializer
@ -255,7 +256,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPriceBreak object """
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(
allow_null=True,

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -17,8 +17,9 @@ from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
@ -551,8 +552,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)

View File

@ -29,7 +29,9 @@
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
</ul>
</div>
@ -169,9 +171,11 @@ $("#place-order").click(function() {
});
{% endif %}
{% if report_enabled %}
$('#print-order-report').click(function() {
printPurchaseOrderReports([{{ order.pk }}]);
});
{% endif %}
$("#edit-order").click(function() {

View File

@ -26,10 +26,11 @@
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span>
</button>
{% endif %}
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span>
</button>
@ -169,6 +170,7 @@ $("#view-list").click(function() {
$("#view-calendar").show();
});
{% if report_enabled %}
$("#order-print").click(function() {
var rows = $("#purchase-order-table").bootstrapTable('getSelections');
@ -180,6 +182,7 @@ $("#order-print").click(function() {
printPurchaseOrderReports(orders);
})
{% endif %}
$("#po-create").click(function() {
createPurchaseOrder();

View File

@ -39,7 +39,9 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
<!--
<li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
@ -206,9 +208,11 @@ $("#ship-order").click(function() {
});
});
{% if report_enabled %}
$('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]);
});
{% endif %}
$('#export-order').click(function() {
exportOrder('{% url "so-export" order.id %}');

View File

@ -29,10 +29,11 @@
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span>
</button>
{% endif %}
<button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
<span class='fas fa-calendar-alt'></span>
</button>
@ -173,6 +174,7 @@ loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
});
{% if report_enabled %}
$("#order-print").click(function() {
var rows = $("#sales-order-table").bootstrapTable('getSelections');
@ -184,6 +186,7 @@ $("#order-print").click(function() {
printSalesOrderReports(orders);
})
{% endif %}
$("#so-create").click(function() {
createSalesOrder();

View File

@ -832,18 +832,6 @@ class PartList(generics.ListCreateAPIView):
queryset = super().filter_queryset(queryset)
# Filter by "uses" query - Limit to parts which use the provided part
uses = params.get('uses', None)
if uses:
try:
uses = Part.objects.get(pk=uses)
queryset = queryset.filter(uses.get_used_in_filter())
except (ValueError, Part.DoesNotExist):
pass
# Exclude specific part ID values?
exclude_id = []
@ -1040,13 +1028,19 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
serializer_class = part_serializers.PartParameterTemplateSerializer
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
filter_fields = [
'name',
]
search_fields = [
'name',
]
class PartParameterList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameter objects
@ -1211,6 +1205,54 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
"""
Filter by 'uses'?
Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part.
There are multiple ways that an assembly can "use" a sub-part:
A) Directly specifying the sub_part in a BomItem field
B) Specifing a "template" part with inherited=True
C) Allowing variant parts to be substituted
D) Allowing direct substitute parts to be specified
- BOM items which are "inherited" by parts which are variants of the master BomItem
"""
uses = params.get('uses', None)
if uses is not None:
try:
# Extract the part we are interested in
uses_part = Part.objects.get(pk=uses)
# Construct the database query in multiple parts
# A) Direct specification of sub_part
q_A = Q(sub_part=uses_part)
# B) BomItem is inherited and points to a "parent" of this part
parents = uses_part.get_ancestors(include_self=False)
q_B = Q(
inherited=True,
sub_part__in=parents
)
# C) Substitution of variant parts
# TODO
# D) Specification of individual substitutes
# TODO
q = q_A | q_B
queryset = queryset.filter(q)
except (ValueError, Part.DoesNotExist):
pass
if self.include_pricing():
queryset = self.annotate_pricing(queryset)

View File

@ -7,7 +7,7 @@ from collections import OrderedDict
from django.utils.translation import gettext as _
from InvenTree.helpers import DownloadFile, GetExportFormats
from InvenTree.helpers import DownloadFile, GetExportFormats, normalize
from .admin import BomItemResource
from .models import BomItem
@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
uids = []
def add_items(items, level):
def add_items(items, level, cascade):
# Add items at a given layer
for item in items:
@ -71,21 +71,13 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
bom_items.append(item)
if item.sub_part.assembly:
if cascade and item.sub_part.assembly:
if max_levels is None or level < max_levels:
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
if cascade:
# Cascading (multi-level) BOM
top_level_items = part.get_bom_items().order_by('id')
# Start with the top level
items_to_process = part.bom_items.all().order_by('id')
add_items(items_to_process, 1)
else:
# No cascading needed - just the top-level items
bom_items = [item for item in part.bom_items.all().order_by('id')]
add_items(top_level_items, 1, cascade)
dataset = BomItemResource().export(queryset=bom_items, cascade=cascade)
@ -148,8 +140,9 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
stock_data.append('')
except AttributeError:
stock_data.append('')
# Get part current stock
stock_data.append(str(bom_item.sub_part.available_stock))
stock_data.append(str(normalize(bom_item.sub_part.available_stock)))
for s_idx, header in enumerate(stock_headers):
try:

View File

@ -1,13 +1,29 @@
# Generated by Django 2.2 on 2019-05-20 12:04
import InvenTree.validators
from django.conf import settings
import django.core.validators
import os
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
import django.core.validators
import InvenTree.validators
import part.models
def attach_file(instance, filename):
"""
Generate a filename for the uploaded attachment.
2021-11-17 - This was moved here from part.models.py,
as the function itself is no longer used,
but is still required for migration
"""
# Construct a path to store a file attachment
return os.path.join('part_files', str(instance.part.id), filename)
class Migration(migrations.Migration):
initial = True
@ -61,7 +77,7 @@ class Migration(migrations.Migration):
name='PartAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=part.models.attach_file)),
('attachment', models.FileField(help_text='Select file to attach', upload_to=attach_file)),
('comment', models.CharField(help_text='File comment', max_length=100)),
],
),

View File

@ -1392,6 +1392,27 @@ class Part(MPTTModel):
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
def get_installed_part_options(self, include_inherited=True, include_variants=True):
"""
Return a set of all Parts which can be "installed" into this part, based on the BOM.
arguments:
include_inherited - If set, include BomItem entries defined for parent parts
include_variants - If set, include variant parts for BomItems which allow variants
"""
parts = set()
for bom_item in self.get_bom_items(include_inherited=include_inherited):
if include_variants and bom_item.allow_variants:
for part in bom_item.sub_part.get_descendants(include_self=True):
parts.add(part)
else:
parts.add(bom_item.sub_part)
return parts
def get_used_in_filter(self, include_inherited=True):
"""
Return a query filter for all parts that this part is used in.
@ -2114,20 +2135,6 @@ def after_save_part(sender, instance: Part, created, **kwargs):
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance)
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: 'part_file_<pk>_filename'
"""
# Construct a path to store a file attachment
return os.path.join('part_files', str(instance.part.id), filename)
class PartAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a Part object

View File

@ -15,6 +15,7 @@ from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeAttachmentSerializer,
@ -120,7 +121,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
Serializer for sale prices for Part model.
"""
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(
allow_null=True
@ -144,7 +145,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
Serializer for internal prices for Part model.
"""
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(
allow_null=True
@ -428,7 +429,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
price_range = serializers.CharField(read_only=True)
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))

View File

@ -164,7 +164,9 @@
<li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>

View File

@ -373,7 +373,9 @@
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
{% endif %}
</ul>
</div>
<!-- Actions menu -->
@ -386,9 +388,7 @@
{% if part.variant_of %}
<li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
{% endif %}
{% if not part.is_bom_valid %}
<li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
{% endif %}
</ul>
</div>
@ -647,14 +647,10 @@
// Load the "used in" tab
onPanelLoad("used-in", function() {
loadPartTable('#used-table',
'{% url "api-part-list" %}',
{
params: {
uses: {{ part.pk }},
},
filterTarget: '#filter-list-usedin',
}
loadUsedInTable(
'#used-table',
{{ part.pk }},
);
});
@ -766,9 +762,11 @@
);
});
{% if report_enabled %}
$("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]);
});
{% endif %}
});
// Load the "related parts" tab

View File

@ -90,6 +90,13 @@ def inventree_in_debug_mode(*args, **kwargs):
return djangosettings.DEBUG
@register.simple_tag()
def inventree_demo_mode(*args, **kwargs):
""" Return True if the server is running in DEMO mode """
return djangosettings.DEMO_MODE
@register.simple_tag()
def inventree_docker_mode(*args, **kwargs):
""" Return True if the server is running as a Docker image """
@ -251,6 +258,15 @@ def global_settings(*args, **kwargs):
return InvenTreeSetting.allValues()
@register.simple_tag()
def visible_global_settings(*args, **kwargs):
"""
Return any global settings which are not marked as 'hidden'
"""
return InvenTreeSetting.allValues(exclude_hidden=True)
@register.simple_tag()
def progress_bar(val, max, *args, **kwargs):
"""
@ -292,6 +308,19 @@ def progress_bar(val, max, *args, **kwargs):
@register.simple_tag()
def get_color_theme_css(username):
user_theme_name = get_user_color_theme(username)
# Build path to CSS sheet
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
# Build static URL
inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet)
return inventree_css_static_url
@register.simple_tag()
def get_user_color_theme(username):
""" Get current user color theme """
try:
user_theme = ColorTheme.objects.filter(user=username).get()
user_theme_name = user_theme.name
@ -300,13 +329,7 @@ def get_color_theme_css(username):
except ColorTheme.DoesNotExist:
user_theme_name = 'default'
# Build path to CSS sheet
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
# Build static URL
inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet)
return inventree_css_static_url
return user_theme_name
@register.simple_tag()

View File

@ -1123,6 +1123,59 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5)
def test_bom_item_uses(self):
"""
Tests for the 'uses' field
"""
url = reverse('api-bom-list')
# Test that the direct 'sub_part' association works
assemblies = []
for i in range(5):
assy = Part.objects.create(
name=f"Assy_{i}",
description="An assembly made of other parts",
active=True,
assembly=True
)
assemblies.append(assy)
components = []
# Create some sub-components
for i in range(5):
cmp = Part.objects.create(
name=f"Component_{i}",
description="A sub component",
active=True,
component=True
)
for j in range(i):
# Create a BOM item
BomItem.objects.create(
quantity=10,
part=assemblies[j],
sub_part=cmp,
)
components.append(cmp)
response = self.get(
url,
{
'uses': cmp.pk,
},
expected_code=200,
)
self.assertEqual(len(response.data), i)
class PartParameterTest(InvenTreeAPITestCase):
"""

View File

@ -117,6 +117,8 @@ class StockItemResource(ModelResource):
exclude = [
# Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level',
# Exclude internal fields
'serial_int',
]

View File

@ -69,6 +69,13 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return queryset
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['user'] = getattr(self.request, 'user', None)
return ctx
def get_serializer(self, *args, **kwargs):
kwargs['part_detail'] = True
@ -79,16 +86,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return self.serializer_class(*args, **kwargs)
def update(self, request, *args, **kwargs):
"""
Record the user who updated the item
"""
# TODO: Record the user!
# user = request.user
return super().update(request, *args, **kwargs)
def perform_destroy(self, instance):
"""
Instead of "deleting" the StockItem
@ -392,6 +389,13 @@ class StockList(generics.ListCreateAPIView):
queryset = StockItem.objects.all()
filterset_class = StockFilter
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['user'] = getattr(self.request, 'user', None)
return ctx
def create(self, request, *args, **kwargs):
"""
Create a new StockItem object via the API.
@ -876,6 +880,7 @@ class StockList(generics.ListCreateAPIView):
ordering_field_aliases = {
'SKU': 'supplier_part__SKU',
'stock': ['quantity', 'serial_int', 'serial'],
}
ordering_fields = [
@ -887,6 +892,7 @@ class StockList(generics.ListCreateAPIView):
'stocktake_date',
'expiry_date',
'quantity',
'stock',
'status',
'SKU',
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-11-09 23:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0067_alter_stockitem_part'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='serial_int',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,54 @@
# Generated by Django 3.2.5 on 2021-11-09 23:47
import re
from django.db import migrations
def update_serials(apps, schema_editor):
"""
Rebuild the integer serial number field for existing StockItem objects
"""
StockItem = apps.get_model('stock', 'stockitem')
for item in StockItem.objects.all():
if item.serial is None:
# Skip items without existing serial numbers
continue
serial = 0
result = re.match(r"^(\d+)", str(item.serial))
if result and len(result.groups()) == 1:
try:
serial = int(result.groups()[0])
except:
serial = 0
item.serial_int = serial
item.save()
def nupdate_serials(apps, schema_editor):
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration):
dependencies = [
('stock', '0068_stockitem_serial_int'),
]
operations = [
migrations.RunPython(
update_serials,
reverse_code=nupdate_serials,
)
]

View File

@ -7,6 +7,7 @@ Stock database model definitions
from __future__ import unicode_literals
import os
import re
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError
@ -223,6 +224,32 @@ class StockItem(MPTTModel):
self.scheduled_for_deletion = True
self.save()
def update_serial_number(self):
"""
Update the 'serial_int' field, to be an integer representation of the serial number.
This is used for efficient numerical sorting
"""
serial = getattr(self, 'serial', '')
# Default value if we cannot convert to an integer
serial_int = 0
if serial is not None:
serial = str(serial)
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', serial)
if result and len(result.groups()) == 1:
try:
serial_int = int(result.groups()[0])
except:
serial_int = 0
self.serial_int = serial_int
def save(self, *args, **kwargs):
"""
Save this StockItem to the database. Performs a number of checks:
@ -234,17 +261,19 @@ class StockItem(MPTTModel):
self.validate_unique()
self.clean()
self.update_serial_number()
user = kwargs.pop('user', None)
if user is None:
user = getattr(self, '_user', None)
# If 'add_note = False' specified, then no tracking note will be added for item creation
add_note = kwargs.pop('add_note', True)
notes = kwargs.pop('notes', '')
if not self.pk:
# StockItem has not yet been saved
add_note = add_note and True
else:
if self.pk:
# StockItem has already been saved
# Check if "interesting" fields have been changed
@ -272,11 +301,10 @@ class StockItem(MPTTModel):
except (ValueError, StockItem.DoesNotExist):
pass
add_note = False
super(StockItem, self).save(*args, **kwargs)
if add_note:
# If user information is provided, and no existing note exists, create one!
if user and self.tracking_info.count() == 0:
tracking_info = {
'status': self.status,
@ -504,6 +532,8 @@ class StockItem(MPTTModel):
help_text=_('Serial number for this item')
)
serial_int = models.IntegerField(default=0)
link = InvenTreeURLField(
verbose_name=_('External Link'),
max_length=125, blank=True,

View File

@ -32,6 +32,7 @@ from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
from InvenTree.serializers import InvenTreeDecimalField
from part.serializers import PartBriefSerializer
@ -55,7 +56,8 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
location_name = serializers.CharField(source='location', read_only=True)
part_name = serializers.CharField(source='part.full_name', read_only=True)
quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
class Meta:
model = StockItem
@ -79,6 +81,15 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
- Includes serialization for the item location
"""
def update(self, instance, validated_data):
"""
Custom update method to pass the user information through to the instance
"""
instance._user = self.context['user']
return super().update(instance, validated_data)
@staticmethod
def annotate_queryset(queryset):
"""
@ -136,7 +147,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False)
# quantity = serializers.FloatField()
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocation_count', required=False)

View File

@ -560,9 +560,8 @@ class StockItemInstall(AjaxUpdateView):
# Filter for parts to install in this item
if self.install_item:
# Get parts used in this part's BOM
bom_items = self.part.get_bom_items()
allowed_parts = [item.sub_part for item in bom_items]
# Get all parts which can be installed into this part
allowed_parts = self.part.get_installed_part_options()
# Filter
items = items.filter(part__in=allowed_parts)

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %}

View File

@ -4,6 +4,9 @@
{% load static %}
{% load inventree_extras %}
{% block breadcrumb_list %}
{% endblock %}
{% block page_title %}
{% inventree_title %} | {% trans "Settings" %}
{% endblock %}

View File

@ -12,12 +12,15 @@
{% endblock %}
{% block actions %}
{% inventree_demo_mode as demo %}
{% if not demo %}
<div class='btn btn-primary' type='button' id='edit-user' title='{% trans "Edit User Information" %}'>
<span class='fas fa-user-cog'></span> {% trans "Edit" %}
</div>
<div class='btn btn-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
<span class='fas fa-key'></span> {% trans "Set Password" %}
</div>
{% endif %}
{% endblock %}
{% block content %}
@ -235,7 +238,6 @@
</div>
</form>
</div>
</div>
<div class="row">
<div class='panel-heading'>

View File

@ -21,4 +21,33 @@
</table>
</div>
<div class='panel-heading'>
<h4>{% trans "Theme Settings" %}</h4>
</div>
<div class='row'>
<div class='col-sm-6'>
<form action='{% url "settings-appearance" %}' method='post'>
{% csrf_token %}
<input name='next' type='hidden' value='{% url "settings" %}'>
<label for='theme' class=' requiredField'>
{% trans "Select theme" %}
</label>
<div class='form-group input-group mb-3'>
<select id='theme' name='theme' class='select form-control'>
{% get_available_themes as themes %}
{% get_user_color_theme request.user.username as user_theme %}
{% for theme in themes %}
<option value='{{ theme.key }}'{% if theme.key == user_theme %} selected{% endif%}>{{ theme.name }}</option>
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary">
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "account/base.html" %}
{% load inventree_extras %}
{% load i18n account socialaccount crispy_forms_tags inventree_extras %}
{% block head_title %}{% trans "Sign In" %}{% endblock %}
@ -10,6 +11,7 @@
{% settings_value 'LOGIN_ENABLE_PWD_FORGOT' as enable_pwd_forgot %}
{% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %}
{% mail_configured as mail_conf %}
{% inventree_demo_mode as demo %}
<h1>{% trans "Sign In" %}</h1>
@ -36,9 +38,16 @@ for a account and sign in below:{% endblocktrans %}</p>
<div class="btn-group float-right" role="group">
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
</div>
{% if mail_conf and enable_pwd_forgot %}
{% if mail_conf and enable_pwd_forgot and not demo %}
<a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
{% endif %}
{% if demo %}
<p>
<h6>
{% trans "InvenTree demo instance" %} - <a href='https://inventree.readthedocs.io/en/latest/demo/'>{% trans "Click here for login details" %}</a>
</h6>
</p>
{% endif %}
</form>
{% if enable_sso %}

View File

@ -4,6 +4,8 @@
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
<!DOCTYPE html>
<html lang="en">
@ -84,10 +86,20 @@
</div>
</div>
<main class='col ps-md-2 pt-2 pe-2'>
{% block alerts %}
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
{% if server_restart_required %}
<div id='alert-restart-server' class='alert alert-danger' role='alert'>
<span class='fas fa-server'></span>
<b>{% trans "Server Restart Required" %}</b>
<small>
<br>
{% trans "A configuration option has been changed which requires a server restart" %}. {% trans "Contact your system administrator for further information" %}
</small>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -13,7 +13,7 @@ const user_settings = {
{% endfor %}
};
{% global_settings as GLOBAL_SETTINGS %}
{% visible_global_settings as GLOBAL_SETTINGS %}
const global_settings = {
{% for key, value in GLOBAL_SETTINGS.items %}
{{ key }}: {% primitive_to_javascript value %},

View File

@ -217,8 +217,10 @@ function showApiError(xhr, url) {
break;
}
message += '<hr>';
message += `URL: ${url}`;
if (url) {
message += '<hr>';
message += `URL: ${url}`;
}
showMessage(title, {
style: 'danger',

View File

@ -16,6 +16,7 @@
/* exported
newPartFromBomWizard,
loadBomTable,
loadUsedInTable,
removeRowFromBomWizard,
removeColFromBomWizard,
*/
@ -311,7 +312,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
}
function loadBomTable(table, options) {
function loadBomTable(table, options={}) {
/* Load a BOM table with some configurable options.
*
* Following options are available:
@ -395,7 +396,7 @@ function loadBomTable(table, options) {
var sub_part = row.sub_part_detail;
html += makePartIcons(row.sub_part_detail);
html += makePartIcons(sub_part);
if (row.substitutes && row.substitutes.length > 0) {
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}');
@ -672,8 +673,9 @@ function loadBomTable(table, options) {
table.treegrid('collapseAll');
},
error: function() {
error: function(xhr) {
console.log('Error requesting BOM for part=' + part_pk);
showApiError(xhr);
}
}
);
@ -835,3 +837,166 @@ function loadBomTable(table, options) {
});
}
}
/*
* Load a table which shows the assemblies which "require" a certain part.
*
* Arguments:
* - table: The ID string of the table element e.g. '#used-in-table'
* - part_id: The ID (PK) of the part we are interested in
*
* Options:
* -
*
* The following "options" are available.
*/
function loadUsedInTable(table, part_id, options={}) {
var params = options.params || {};
params.uses = part_id;
params.part_detail = true;
params.sub_part_detail = true,
params.show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters('usedin');
}
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin');
function loadVariantData(row) {
// Load variants information for inherited BOM rows
inventreeGet(
'{% url "api-part-list" %}',
{
assembly: true,
ancestor: row.part,
},
{
success: function(variantData) {
// Iterate through each variant item
for (var jj = 0; jj < variantData.length; jj++) {
variantData[jj].parent = row.pk;
var variant = variantData[jj];
// Add this variant to the table, augmented
$(table).bootstrapTable('append', [{
// Point the parent to the "master" assembly row
parent: row.pk,
part: variant.pk,
part_detail: variant,
sub_part: row.sub_part,
sub_part_detail: row.sub_part_detail,
quantity: row.quantity,
}]);
}
},
error: function(xhr) {
showApiError(xhr);
}
}
);
}
$(table).inventreeTable({
url: options.url || '{% url "api-bom-list" %}',
name: options.table_name || 'usedin',
sortable: true,
search: true,
showColumns: true,
queryParams: filters,
original: params,
rootParentId: 'top-level-item',
idField: 'pk',
uniqueId: 'pk',
parentIdField: 'parent',
treeShowField: 'part',
onLoadSuccess: function(tableData) {
// Once the initial data are loaded, check if there are any "inherited" BOM lines
for (var ii = 0; ii < tableData.length; ii++) {
var row = tableData[ii];
// This is a "top level" item in the table
row.parent = 'top-level-item';
// Ignore this row as it is not "inherited" by variant parts
if (!row.inherited) {
continue;
}
loadVariantData(row);
}
},
onPostBody: function() {
$(table).treegrid({
treeColumn: 0,
});
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part',
title: '{% trans "Assembly" %}',
switchable: false,
sortable: true,
formatter: function(value, row) {
var url = `/part/${value}/?display=bom`;
var html = '';
var part = row.part_detail;
html += imageHoverIcon(part.thumbnail);
html += renderLink(part.full_name, url);
html += makePartIcons(part);
return html;
}
},
{
field: 'sub_part',
title: '{% trans "Required Part" %}',
sortable: true,
formatter: function(value, row) {
var url = `/part/${value}/`;
var html = '';
var sub_part = row.sub_part_detail;
html += imageHoverIcon(sub_part.thumbnail);
html += renderLink(sub_part.full_name, url);
html += makePartIcons(sub_part);
return html;
}
},
{
field: 'quantity',
title: '{% trans "Required Quantity" %}',
formatter: function(value, row) {
var html = value;
if (row.parent && row.parent != 'top-level-item') {
html += ` <em>({% trans "Inherited from parent BOM" %})</em>`;
}
return html;
}
}
]
});
}

View File

@ -281,23 +281,24 @@ function setupFilterList(tableKey, table, target) {
// One blank slate, please
element.empty();
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`);
var buttons = '';
// Callback for reloading the table
element.find(`#reload-${tableKey}`).click(function() {
$(table).bootstrapTable('refresh');
});
buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`;
// If there are no filters defined for this table, exit now
if (jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
return;
// If there are filters defined for this table, add more buttons
if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
buttons += `<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`;
if (Object.keys(filters).length > 0) {
buttons += `<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`;
}
}
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`);
if (Object.keys(filters).length > 0) {
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`);
}
element.html(`
<div class='btn-group' role='group'>
${buttons}
</div>
`);
for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]);
@ -307,6 +308,11 @@ function setupFilterList(tableKey, table, target) {
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
}
// Callback for reloading the table
element.find(`#reload-${tableKey}`).click(function() {
$(table).bootstrapTable('refresh');
});
// Add a callback for adding a new filter
element.find(`#${add}`).click(function clicked() {
@ -316,10 +322,12 @@ function setupFilterList(tableKey, table, target) {
var html = '';
html += `<div class='input-group'>`;
html += generateAvailableFilterList(tableKey);
html += generateFilterInput(tableKey);
html += `<button title='{% trans "Create filter" %}' class='btn btn-outline-secondary filter-button' id='${make}'><span class='fas fa-plus'></span></button>`;
html += `</div>`;
element.append(html);

View File

@ -924,8 +924,8 @@ function handleFormSuccess(response, options) {
var cache = (options.follow && response.url) || options.redirect || options.reload;
// Display any messages
if (response && response.success) {
showAlertOrCache(response.success, cache, {style: 'success'});
if (response && (response.success || options.successMessage)) {
showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'});
}
if (response && response.info) {

View File

@ -331,6 +331,7 @@ function editPart(pk) {
groups: groups,
title: '{% trans "Edit Part" %}',
reload: true,
successMessage: '{% trans "Part edited" %}',
});
}

View File

@ -1128,7 +1128,9 @@ function loadStockTable(table, options) {
col = {
field: 'quantity',
sortName: 'stock',
title: '{% trans "Stock" %}',
sortable: true,
formatter: function(value, row) {
var val = parseFloat(value);

View File

@ -77,10 +77,22 @@ function getAvailableTableFilters(tableKey) {
// Filters for the "used in" table
if (tableKey == 'usedin') {
return {
'inherited': {
type: 'bool',
title: '{% trans "Inherited" %}',
},
'optional': {
type: 'bool',
title: '{% trans "Optional" %}',
},
'part_active': {
type: 'bool',
title: '{% trans "Active" %}',
},
'part_trackable': {
type: 'bool',
title: '{% trans "Trackable" %}',
},
};
}

View File

@ -4,6 +4,7 @@
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'STICKY_HEADER' user=request.user as sticky %}
{% inventree_demo_mode as demo %}
<nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light">
<div class="container-fluid">
@ -58,6 +59,9 @@
{% endif %}
</ul>
</div>
{% if demo %}
{% include "navbar_demo.html" %}
{% endif %}
{% include "search_form.html" %}
<ul class='navbar-nav flex-row'>
{% if barcodes %}
@ -78,7 +82,7 @@
</a>
<ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
{% if user.is_authenticated %}
{% if user.is_staff %}
{% if user.is_staff and not demo %}
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>

View File

@ -0,0 +1,12 @@
{% load i18n %}
{% include "spacer.html" %}
<div class='flex'>
<h6>
{% trans "InvenTree demo mode" %}
<a href='https://inventree.readthedocs.io/en/latest/demo/'>
<span class='fas fa-info-circle'></span>
</a>
</h6>
</div>
{% include "spacer.html" %}
{% include "spacer.html" %}

View File

@ -2,8 +2,10 @@
<form class="d-flex" action="{% url 'search' %}" method='post'>
{% csrf_token %}
<input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-bar" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}>
<button type="submit" id='search-submit' class="btn btn-secondary" title='{% trans "Search" %}'>
<span class='fas fa-search'></span>
</button>
<div class='input-group'>
<input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-bar" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}>
<button type="submit" id='search-submit' class="btn btn-secondary" title='{% trans "Search" %}'>
<span class='fas fa-search'></span>
</button>
</div>
</form>

View File

@ -1,7 +1,7 @@
{% load i18n %}
<a href="#" id='select-{{ label }}' title='{% trans text %}' class="list-group-item sidebar-list-group-item border-end-0 d-inline-block text-truncate sidebar-selector" data-bs-parent="#sidebar">
<i class="bi bi-bootstrap"></i>
<span class='sidebar-item-icon fas {{ icon }}'></span>
<span class='sidebar-item-icon fas {{ icon|default:"fa-circle" }}'></span>
<span class='sidebar-item-text' style='display: none;'>{% trans text %}</span>
{% if badge %}
<span id='sidebar-badge-{{ label }}' class='sidebar-item-badge badge rounded-pill badge-right bg-dark'>

View File

@ -27,6 +27,9 @@ function {{ label }}StatusDisplay(key, options={}) {
label = {{ label }}Codes[key].label;
}
// Fallback option for label
label = label || 'bg-dark';
if (value == null || value.length == 0) {
value = key;
label = '';

View File

@ -9,7 +9,6 @@
![SQLite](https://github.com/inventree/inventree/actions/workflows/coverage.yaml/badge.svg)
![MySQL](https://github.com/inventree/inventree/actions/workflows/mysql.yaml/badge.svg)
![PostgreSQL](https://github.com/inventree/inventree/actions/workflows/postgresql.yaml/badge.svg)
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/InvenTree/InvenTree)
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications.
@ -17,6 +16,10 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl
However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
# Demo
A demo instance of InvenTree is provided to allow users to explore the functionality of the software. [Read more here](https://inventree.readthedocs.io/en/latest/demo/)
# Docker
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)

View File

@ -28,6 +28,7 @@ print("=================================")
print("Checking static javascript files:")
print("=================================")
def check_invalid_tag(data):
pattern = r"{%(\w+)"
@ -45,6 +46,7 @@ def check_invalid_tag(data):
return err_count
def check_prohibited_tags(data):
allowed_tags = [
@ -78,7 +80,7 @@ def check_prohibited_tags(data):
has_trans = True
if not has_trans:
print(f" > file is missing 'trans' tags")
print(" > file is missing 'trans' tags")
err_count += 1
return err_count

View File

@ -24,7 +24,7 @@ for line in str(out.decode()).split('\n'):
if len(locales) > 0:
print("There are {n} unstaged locale files:".format(n=len(locales)))
for l in locales:
print(" - {l}".format(l=l))
for lang in locales:
print(" - {l}".format(l=lang))
sys.exit(len(locales))
sys.exit(len(locales))

View File

@ -28,4 +28,4 @@ print("There are {n} unstaged migration files:".format(n=len(migrations)))
for m in migrations:
print(" - {m}".format(m=m))
sys.exit(len(migrations))
sys.exit(len(migrations))

View File

@ -9,7 +9,6 @@ import sys
import re
import os
import argparse
import requests
if __name__ == '__main__':
@ -65,7 +64,7 @@ if __name__ == '__main__':
e.g. "0.5 dev"
"""
print(f"Checking development branch")
print("Checking development branch")
pattern = "^\d+(\.\d+)+ dev$"
@ -81,7 +80,7 @@ if __name__ == '__main__':
e.g. "0.5.1"
"""
print(f"Checking release branch")
print("Checking release branch")
pattern = "^\d+(\.\d+)+$"

View File

@ -11,3 +11,6 @@ psycopg2>=2.9.1
mysqlclient>=2.0.3
pgcli>=3.1.0
mariadb>=1.0.7
# Cache
django-redis>=5.0.0

View File

@ -29,6 +29,7 @@ djangorestframework==3.12.4 # DRF framework
flake8==3.8.3 # PEP checking
gunicorn>=20.1.0 # Gunicorn web server
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
py-moneyed==0.8.0 # Specific version requirement for py-moneyed

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
from shutil import copyfile
import os
import json
import sys
@ -134,6 +133,7 @@ def rebuild_models(c):
manage(c, "rebuild_models", pty=True)
@task
def rebuild_thumbnails(c):
"""
@ -142,6 +142,7 @@ def rebuild_thumbnails(c):
manage(c, "rebuild_thumbnails", pty=True)
@task
def clean_settings(c):
"""
@ -150,6 +151,7 @@ def clean_settings(c):
manage(c, "clean_settings")
@task(post=[rebuild_models, rebuild_thumbnails])
def migrate(c):
"""
@ -306,7 +308,7 @@ def export_records(c, filename='data.json'):
# Get an absolute path to the file
if not os.path.isabs(filename):
filename = os.path.join(localDir(), filename)
filename = os.path.abspath(filename)
filename = os.path.abspath(filename)
print(f"Exporting database records to file '{filename}'")