mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-05 14:29:47 -05:00
Merge branch 'master' into feat-qr-scanner
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
InvenTree API version information
|
||||
"""
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 43
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
|
||||
- Adds API detail endpoint for PartSalePrice model
|
||||
- Adds API detail endpoint for PartInternalPrice model
|
||||
|
||||
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
|
||||
- Adds variant stock information to the Part and BomItem serializers
|
||||
|
||||
v41 -> 2022-04-26
|
||||
- Fixes 'variant_of' filter for Part list endpoint
|
||||
|
||||
v40 -> 2022-04-19
|
||||
- Adds ability to filter StockItem list by "tracked" parameter
|
||||
- This checks the serial number or batch code fields
|
||||
|
||||
v39 -> 2022-04-18
|
||||
- Adds ability to filter StockItem list by "has_batch" parameter
|
||||
|
||||
v38 -> 2022-04-14 : https://github.com/inventree/InvenTree/pull/2828
|
||||
- Adds the ability to include stock test results for "installed items"
|
||||
|
||||
v37 -> 2022-04-07 : https://github.com/inventree/InvenTree/pull/2806
|
||||
- Adds extra stock availability information to the BomItem serializer
|
||||
|
||||
v36 -> 2022-04-03
|
||||
- Adds ability to filter part list endpoint by unallocated_stock argument
|
||||
|
||||
v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797
|
||||
- Adds stock allocation information to the Part API
|
||||
- Adds calculated field for "unallocated_quantity"
|
||||
|
||||
v34 -> 2022-03-25
|
||||
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
|
||||
|
||||
v33 -> 2022-03-24
|
||||
- Adds "plugins_enabled" information to root API endpoint
|
||||
|
||||
v32 -> 2022-03-19
|
||||
- Adds "parameters" detail to Part API endpoint (use ¶meters=true)
|
||||
- Adds ability to filter PartParameterTemplate API by Part instance
|
||||
- Adds ability to filter PartParameterTemplate API by PartCategory instance
|
||||
|
||||
v31 -> 2022-03-14
|
||||
- Adds "updated" field to SupplierPriceBreakList and SupplierPriceBreakDetail API endpoints
|
||||
|
||||
v30 -> 2022-03-09
|
||||
- Adds "exclude_location" field to BuildAutoAllocation API endpoint
|
||||
- Allows BuildItem API endpoint to be filtered by BomItem relation
|
||||
|
||||
v29 -> 2022-03-08
|
||||
- Adds "scheduling" endpoint for predicted stock scheduling information
|
||||
|
||||
v28 -> 2022-03-04
|
||||
- Adds an API endpoint for auto allocation of stock items against a build order
|
||||
- Ref: https://github.com/inventree/InvenTree/pull/2713
|
||||
|
||||
v27 -> 2022-02-28
|
||||
- Adds target_date field to individual line items for purchase orders and sales orders
|
||||
|
||||
v26 -> 2022-02-17
|
||||
- Adds API endpoint for uploading a BOM file and extracting data
|
||||
|
||||
v25 -> 2022-02-17
|
||||
- Adds ability to filter "part" list endpoint by "in_bom_for" argument
|
||||
|
||||
v24 -> 2022-02-10
|
||||
- Adds API endpoint for deleting (cancelling) build order outputs
|
||||
|
||||
v23 -> 2022-02-02
|
||||
- Adds API endpoints for managing plugin classes
|
||||
- Adds API endpoints for managing plugin settings
|
||||
|
||||
v22 -> 2021-12-20
|
||||
- Adds API endpoint to "merge" multiple stock items
|
||||
|
||||
v21 -> 2021-12-04
|
||||
- Adds support for multiple "Shipments" against a SalesOrder
|
||||
- Refactors process for stock allocation against a SalesOrder
|
||||
|
||||
v20 -> 2021-12-03
|
||||
- Adds ability to filter POLineItem endpoint by "base_part"
|
||||
- Adds optional "order_detail" to POLineItem list endpoint
|
||||
|
||||
v19 -> 2021-12-02
|
||||
- Adds the ability to filter the StockItem API by "part_tree"
|
||||
- Returns only stock items which match a particular part.tree_id field
|
||||
|
||||
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
|
||||
|
||||
v16 -> 2021-10-17
|
||||
- Adds API endpoint for completing build order outputs
|
||||
|
||||
v15 -> 2021-10-06
|
||||
- Adds detail endpoint for SalesOrderAllocation model
|
||||
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
|
||||
|
||||
v14 -> 2021-10-05
|
||||
- Stock adjustment actions API is improved, using native DRF serializer support
|
||||
- However adjustment actions now only support 'pk' as a lookup field
|
||||
|
||||
v13 -> 2021-10-05
|
||||
- Adds API endpoint to allocate stock items against a BuildOrder
|
||||
- Updates StockItem API with improved filtering against BomItem data
|
||||
|
||||
v12 -> 2021-09-07
|
||||
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||
|
||||
v11 -> 2021-08-26
|
||||
- Adds "units" field to PartBriefSerializer
|
||||
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
|
||||
|
||||
v10 -> 2021-08-23
|
||||
- Adds "purchase_price_currency" to StockItem serializer
|
||||
- Adds "purchase_price_string" to StockItem serializer
|
||||
- Purchase price is now writable for StockItem serializer
|
||||
|
||||
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
|
||||
|
||||
v7 -> 2021-07-03
|
||||
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
|
||||
- API OPTIONS endpoints provide comprehensive field metedata
|
||||
- Multiple new API endpoints added for database models
|
||||
|
||||
v6 -> 2021-06-23
|
||||
- Part and Company images can now be directly uploaded via the REST API
|
||||
|
||||
v5 -> 2021-06-21
|
||||
- Adds API interface for manufacturer part parameters
|
||||
|
||||
v4 -> 2021-06-01
|
||||
- BOM items can now accept "variant stock" to be assigned against them
|
||||
- Many slight API tweaks were needed to get this to work properly!
|
||||
|
||||
v3 -> 2021-05-22:
|
||||
- The updated StockItem "history tracking" now uses a different interface
|
||||
|
||||
"""
|
||||
@@ -150,13 +150,13 @@ class DeleteForm(forms.Form):
|
||||
|
||||
|
||||
class EditUserForm(HelperForm):
|
||||
""" Form for editing user information
|
||||
"""
|
||||
Form for editing user information
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
]
|
||||
|
||||
@@ -427,8 +427,9 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
serials = serials.strip()
|
||||
|
||||
# fill in the next serial number into the serial
|
||||
if '~' in serials:
|
||||
serials = serials.replace('~', str(next_number))
|
||||
while '~' in serials:
|
||||
serials = serials.replace('~', str(next_number), 1)
|
||||
next_number += 1
|
||||
|
||||
# Split input string by whitespace or comma (,) characters
|
||||
groups = re.split("[\s,]+", serials)
|
||||
@@ -438,6 +439,12 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
|
||||
# Helper function to check for duplicated numbers
|
||||
def add_sn(sn):
|
||||
# Attempt integer conversion first, so numerical strings are never stored
|
||||
try:
|
||||
sn = int(sn)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if sn in numbers:
|
||||
errors.append(_('Duplicate serial: {sn}').format(sn=sn))
|
||||
else:
|
||||
@@ -451,15 +458,25 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
if len(serials) == 0:
|
||||
raise ValidationError([_("Empty serial number string")])
|
||||
|
||||
for group in groups:
|
||||
# If the user has supplied the correct number of serials, don't process them for groups
|
||||
# just add them so any duplicates (or future validations) are checked
|
||||
if len(groups) == expected_quantity:
|
||||
for group in groups:
|
||||
add_sn(group)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
return numbers
|
||||
|
||||
for group in groups:
|
||||
group = group.strip()
|
||||
|
||||
# Hyphen indicates a range of numbers
|
||||
if '-' in group:
|
||||
items = group.split('-')
|
||||
|
||||
if len(items) == 2:
|
||||
if len(items) == 2 and all([i.isnumeric() for i in items]):
|
||||
a = items[0].strip()
|
||||
b = items[1].strip()
|
||||
|
||||
@@ -471,13 +488,14 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
for n in range(a, b + 1):
|
||||
add_sn(n)
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
errors.append(_("Invalid group range: {g}").format(g=group))
|
||||
|
||||
except ValueError:
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
continue
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
# More than 2 hyphens or non-numeric group so add without interpolating
|
||||
add_sn(group)
|
||||
|
||||
# plus signals either
|
||||
# 1: 'start+': expected number of serials, starting at start
|
||||
@@ -495,23 +513,17 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||
|
||||
# case 1
|
||||
else:
|
||||
end = start + expected_quantity
|
||||
end = start + (expected_quantity - len(numbers))
|
||||
|
||||
for n in range(start, end):
|
||||
add_sn(n)
|
||||
# no case
|
||||
else:
|
||||
errors.append(_("Invalid group: {g}").format(g=group))
|
||||
errors.append(_("Invalid group sequence: {g}").format(g=group))
|
||||
|
||||
# At this point, we assume that the "group" is just a single serial value
|
||||
elif group:
|
||||
|
||||
try:
|
||||
# First attempt to add as an integer value
|
||||
add_sn(int(group))
|
||||
except (ValueError):
|
||||
# As a backup, add as a string value
|
||||
add_sn(group)
|
||||
add_sn(group)
|
||||
|
||||
# No valid input group detected
|
||||
else:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy, Resolver404
|
||||
from django.db import connection
|
||||
from django.shortcuts import redirect
|
||||
from django.conf.urls import include, url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||
|
||||
import logging
|
||||
import time
|
||||
import operator
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
|
||||
@@ -92,67 +92,6 @@ class AuthRequiredMiddleware(object):
|
||||
return response
|
||||
|
||||
|
||||
class QueryCountMiddleware(object):
|
||||
"""
|
||||
This middleware will log the number of queries run
|
||||
and the total time taken for each request (with a
|
||||
status code of 200). It does not currently support
|
||||
multi-db setups.
|
||||
|
||||
To enable this middleware, set 'log_queries: True' in the local InvenTree config file.
|
||||
|
||||
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
|
||||
|
||||
Note: 2020-08-15 - This is no longer used, instead we now rely on the django-debug-toolbar addon
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
|
||||
t_start = time.time()
|
||||
response = self.get_response(request)
|
||||
t_stop = time.time()
|
||||
|
||||
if response.status_code == 200:
|
||||
total_time = 0
|
||||
|
||||
if len(connection.queries) > 0:
|
||||
|
||||
queries = {}
|
||||
|
||||
for query in connection.queries:
|
||||
query_time = query.get('time')
|
||||
|
||||
sql = query.get('sql').split('.')[0]
|
||||
|
||||
if sql in queries:
|
||||
queries[sql] += 1
|
||||
else:
|
||||
queries[sql] = 1
|
||||
|
||||
if query_time is None:
|
||||
# django-debug-toolbar monkeypatches the connection
|
||||
# cursor wrapper and adds extra information in each
|
||||
# item in connection.queries. The query time is stored
|
||||
# under the key "duration" rather than "time" and is
|
||||
# in milliseconds, not seconds.
|
||||
query_time = float(query.get('duration', 0))
|
||||
|
||||
total_time += float(query_time)
|
||||
|
||||
logger.debug('{n} queries run, {a:.3f}s / {b:.3f}s'.format(
|
||||
n=len(connection.queries),
|
||||
a=total_time,
|
||||
b=(t_stop - t_start)))
|
||||
|
||||
for x in sorted(queries.items(), key=operator.itemgetter(1), reverse=True):
|
||||
print(x[0], ':', x[1])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
url_matcher = url('', include(frontendpatterns))
|
||||
|
||||
|
||||
@@ -176,3 +115,16 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
||||
super().process_request(request)
|
||||
except Resolver404:
|
||||
pass
|
||||
|
||||
|
||||
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
||||
"""
|
||||
Middleware to check if HTTP-header based auth is enabled and to set it up
|
||||
"""
|
||||
header = settings.REMOTE_LOGIN_HEADER
|
||||
|
||||
def process_request(self, request):
|
||||
if not settings.REMOTE_LOGIN:
|
||||
return
|
||||
|
||||
return super().process_request(request)
|
||||
|
||||
@@ -25,6 +25,7 @@ import moneyed
|
||||
import yaml
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.files.storage import default_storage
|
||||
import django.conf.locale
|
||||
|
||||
from .config import get_base_dir, get_config_file, get_plugin_file, get_setting
|
||||
@@ -61,12 +62,6 @@ 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
|
||||
@@ -216,9 +211,6 @@ MEDIA_URL = '/media/'
|
||||
if DEBUG:
|
||||
logger.info("InvenTree running with DEBUG enabled")
|
||||
|
||||
if DEMO_MODE:
|
||||
logger.warning("InvenTree running in DEMO mode") # pragma: no cover
|
||||
|
||||
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
|
||||
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
|
||||
|
||||
@@ -282,12 +274,14 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = CONFIG.get('middleware', [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'x_forwarded_for.middleware.XForwardedForMiddleware',
|
||||
'user_sessions.middleware.SessionMiddleware', # db user sessions
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
|
||||
'django_otp.middleware.OTPMiddleware', # MFA support
|
||||
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
@@ -301,6 +295,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
|
||||
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
|
||||
|
||||
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
|
||||
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
|
||||
])
|
||||
@@ -549,11 +544,19 @@ if "sqlite" in db_engine:
|
||||
# Provide OPTIONS dict back to the database configuration dict
|
||||
db_config['OPTIONS'] = db_options
|
||||
|
||||
# Set testing options for the database
|
||||
db_config['TEST'] = {
|
||||
'CHARSET': 'utf8',
|
||||
}
|
||||
|
||||
# Set collation option for mysql test database
|
||||
if 'mysql' in db_engine:
|
||||
db_config['TEST']['COLLATION'] = 'utf8_general_ci'
|
||||
|
||||
DATABASES = {
|
||||
'default': db_config
|
||||
}
|
||||
|
||||
|
||||
_cache_config = CONFIG.get("cache", {})
|
||||
_cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST"))
|
||||
_cache_port = _cache_config.get(
|
||||
@@ -666,11 +669,13 @@ LANGUAGE_CODE = CONFIG.get('language', 'en-us')
|
||||
|
||||
# If a new language translation is supported, it must be added here
|
||||
LANGUAGES = [
|
||||
('cs', _('Czech')),
|
||||
('de', _('German')),
|
||||
('el', _('Greek')),
|
||||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('es-mx', _('Spanish (Mexican)')),
|
||||
('fa', _('Farsi / Persian')),
|
||||
('fr', _('French')),
|
||||
('he', _('Hebrew')),
|
||||
('hu', _('Hungarian')),
|
||||
@@ -680,7 +685,8 @@ LANGUAGES = [
|
||||
('nl', _('Dutch')),
|
||||
('no', _('Norwegian')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portugese')),
|
||||
('pt', _('Portuguese')),
|
||||
('pt-BR', _('Portuguese (Brazilian)')),
|
||||
('ru', _('Russian')),
|
||||
('sv', _('Swedish')),
|
||||
('th', _('Thai')),
|
||||
@@ -846,6 +852,10 @@ ACCOUNT_FORMS = {
|
||||
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
||||
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
|
||||
|
||||
# login settings
|
||||
REMOTE_LOGIN = get_setting('INVENTREE_REMOTE_LOGIN', CONFIG.get('remote_login', False))
|
||||
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', CONFIG.get('remote_login_header', 'REMOTE_USER'))
|
||||
|
||||
# Markdownx configuration
|
||||
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
||||
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
|
||||
@@ -905,3 +915,20 @@ PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing te
|
||||
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
|
||||
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# User interface customization values
|
||||
CUSTOMIZE = get_setting(
|
||||
'INVENTREE_CUSTOMIZE',
|
||||
CONFIG.get('customize', {}),
|
||||
{}
|
||||
)
|
||||
|
||||
CUSTOM_LOGO = get_setting(
|
||||
'INVENTREE_CUSTOM_LOGO',
|
||||
CUSTOMIZE.get('logo', False)
|
||||
)
|
||||
|
||||
# check that the logo-file exsists in media
|
||||
if CUSTOM_LOGO and not default_storage.exists(CUSTOM_LOGO):
|
||||
CUSTOM_LOGO = False
|
||||
logger.warning("The custom logo file could not be found in the default media storage")
|
||||
|
||||
@@ -1018,4 +1018,33 @@ input[type='number']{
|
||||
a {
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Quicksearch Panel */
|
||||
|
||||
.search-result-panel {
|
||||
max-width: 800px;
|
||||
width: 75%
|
||||
}
|
||||
|
||||
.search-result-group {
|
||||
padding: 5px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-result-group-buttons > button{
|
||||
padding: 2px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.search-result-entry {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 3px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -128,81 +128,6 @@ function inventreeDocReady() {
|
||||
attachClipboard('.clip-btn', 'modal-about');
|
||||
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text');
|
||||
|
||||
// Add autocomplete to the search-bar
|
||||
if ($('#search-bar').exists()) {
|
||||
$('#search-bar').autocomplete({
|
||||
source: function(request, response) {
|
||||
|
||||
var params = {
|
||||
search: request.term,
|
||||
limit: user_settings.SEARCH_PREVIEW_RESULTS,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
||||
// Limit to active parts
|
||||
params.active = true;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/api/part/',
|
||||
data: params,
|
||||
success: function(data) {
|
||||
|
||||
var transformed = $.map(data.results, function(el) {
|
||||
return {
|
||||
label: el.full_name,
|
||||
id: el.pk,
|
||||
thumbnail: el.thumbnail,
|
||||
data: el,
|
||||
};
|
||||
});
|
||||
response(transformed);
|
||||
},
|
||||
error: function() {
|
||||
response([]);
|
||||
}
|
||||
});
|
||||
},
|
||||
create: function() {
|
||||
$(this).data('ui-autocomplete')._renderItem = function(ul, item) {
|
||||
|
||||
var html = `
|
||||
<div class='search-autocomplete-item' title='${item.data.description}'>
|
||||
<a href='/part/${item.id}/'>
|
||||
<span style='padding-right: 10px;'><img class='hover-img-thumb' src='${item.thumbnail || "/static/img/blank_image.png"}'> ${item.label}</span>
|
||||
</a>
|
||||
<span class='flex' style='flex-grow: 1;'></span>
|
||||
`;
|
||||
|
||||
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
|
||||
html += partStockLabel(
|
||||
item.data,
|
||||
{
|
||||
classes: 'badge-right',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return $('<li>').append(html).appendTo(ul);
|
||||
};
|
||||
},
|
||||
select: function( event, ui ) {
|
||||
window.location = '/part/' + ui.item.id + '/';
|
||||
},
|
||||
minLength: 2,
|
||||
classes: {
|
||||
'ui-autocomplete': 'dropdown-menu search-menu',
|
||||
},
|
||||
position: {
|
||||
my : "right top",
|
||||
at: "right bottom"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate brand-icons
|
||||
$('.brand-icon').each(function(i, obj) {
|
||||
loadBrandIcon($(this), $(this).attr('brand_name'));
|
||||
@@ -231,8 +156,13 @@ function inventreeDocReady() {
|
||||
stopNotificationWatcher();
|
||||
});
|
||||
|
||||
$('#offcanvasRight').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel
|
||||
$('#offcanvasRight').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel
|
||||
// Calbacks for search panel
|
||||
$('#offcanvas-search').on('shown.bs.offcanvas', openSearchPanel);
|
||||
$('#offcanvas-search').on('hidden.bs.offcanvas', closeSearchPanel);
|
||||
|
||||
// Callbacks for notifications panel
|
||||
$('#offcanvas-notification').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel
|
||||
$('#offcanvas-notification').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel
|
||||
}
|
||||
|
||||
|
||||
|
||||
+15
-15
@@ -1,4 +1,4 @@
|
||||
/*! jQuery UI - v1.12.1 - 2021-07-18
|
||||
/*! jQuery UI - v1.13.0 - 2021-10-07
|
||||
* http://jqueryui.com
|
||||
* Includes: widget.js, position.js, disable-selection.js, keycode.js, unique-id.js, widgets/resizable.js, widgets/autocomplete.js, widgets/menu.js, widgets/mouse.js
|
||||
* Copyright jQuery Foundation and other contributors; Licensed MIT */
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
$.ui = $.ui || {};
|
||||
|
||||
var version = $.ui.version = "1.12.1";
|
||||
var version = $.ui.version = "1.13.1";
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Widget 1.12.1
|
||||
* jQuery UI Widget 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -744,7 +744,7 @@ var widget = $.widget;
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Position 1.12.1
|
||||
* jQuery UI Position 1.13.1
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -1232,7 +1232,7 @@ var position = $.ui.position;
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Disable Selection 1.12.1
|
||||
* jQuery UI Disable Selection 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -1268,7 +1268,7 @@ var disableSelection = $.fn.extend( {
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Keycode 1.12.1
|
||||
* jQuery UI Keycode 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -1303,7 +1303,7 @@ var keycode = $.ui.keyCode = {
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Unique ID 1.12.1
|
||||
* jQuery UI Unique ID 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -1347,7 +1347,7 @@ var uniqueId = $.fn.extend( {
|
||||
var ie = $.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() );
|
||||
|
||||
/*!
|
||||
* jQuery UI Mouse 1.12.1
|
||||
* jQuery UI Mouse 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -1368,7 +1368,7 @@ $( document ).on( "mouseup", function() {
|
||||
} );
|
||||
|
||||
var widgetsMouse = $.widget( "ui.mouse", {
|
||||
version: "1.12.1",
|
||||
version: "1.13.0",
|
||||
options: {
|
||||
cancel: "input, textarea, button, select, option",
|
||||
distance: 1,
|
||||
@@ -1592,7 +1592,7 @@ var plugin = $.ui.plugin = {
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Resizable 1.12.1
|
||||
* jQuery UI Resizable 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -1612,7 +1612,7 @@ var plugin = $.ui.plugin = {
|
||||
|
||||
|
||||
$.widget( "ui.resizable", $.ui.mouse, {
|
||||
version: "1.12.1",
|
||||
version: "1.13.0",
|
||||
widgetEventPrefix: "resize",
|
||||
options: {
|
||||
alsoResize: false,
|
||||
@@ -2806,7 +2806,7 @@ var safeActiveElement = $.ui.safeActiveElement = function( document ) {
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Menu 1.12.1
|
||||
* jQuery UI Menu 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -2826,7 +2826,7 @@ var safeActiveElement = $.ui.safeActiveElement = function( document ) {
|
||||
|
||||
|
||||
var widgetsMenu = $.widget( "ui.menu", {
|
||||
version: "1.12.1",
|
||||
version: "1.13.0",
|
||||
defaultElement: "<ul>",
|
||||
delay: 300,
|
||||
options: {
|
||||
@@ -3461,7 +3461,7 @@ var widgetsMenu = $.widget( "ui.menu", {
|
||||
|
||||
|
||||
/*!
|
||||
* jQuery UI Autocomplete 1.12.1
|
||||
* jQuery UI Autocomplete 1.13.0
|
||||
* http://jqueryui.com
|
||||
*
|
||||
* Copyright jQuery Foundation and other contributors
|
||||
@@ -3481,7 +3481,7 @@ var widgetsMenu = $.widget( "ui.menu", {
|
||||
|
||||
|
||||
$.widget( "ui.autocomplete", {
|
||||
version: "1.12.1",
|
||||
version: "1.13.0",
|
||||
defaultElement: "<input>",
|
||||
options: {
|
||||
appendTo: null,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"name": "jquery-ui",
|
||||
"title": "jQuery UI",
|
||||
"description": "A curated set of user interface interactions, effects, widgets, and themes built on top of the jQuery JavaScript Library.",
|
||||
"version": "1.12.1",
|
||||
"version": "1.13.0",
|
||||
"homepage": "http://jqueryui.com",
|
||||
"author": {
|
||||
"name": "jQuery Foundation and other contributors",
|
||||
|
||||
@@ -255,6 +255,9 @@ class StockHistoryCode(StatusCode):
|
||||
# Stock merging operations
|
||||
MERGED_STOCK_ITEMS = 45
|
||||
|
||||
# Convert stock item to variant
|
||||
CONVERTED_TO_VARIANT = 48
|
||||
|
||||
# Build order codes
|
||||
BUILD_OUTPUT_CREATED = 50
|
||||
BUILD_OUTPUT_COMPLETED = 55
|
||||
@@ -294,6 +297,8 @@ class StockHistoryCode(StatusCode):
|
||||
|
||||
MERGED_STOCK_ITEMS: _('Merged stock items'),
|
||||
|
||||
CONVERTED_TO_VARIANT: _('Converted to variant'),
|
||||
|
||||
SENT_TO_CUSTOMER: _('Sent to customer'),
|
||||
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class ViewTests(TestCase):
|
||||
"""
|
||||
|
||||
# Change this number as more javascript files are added to the index page
|
||||
N_SCRIPT_FILES = 37
|
||||
N_SCRIPT_FILES = 39
|
||||
|
||||
content = self.get_index_page()
|
||||
|
||||
|
||||
@@ -252,6 +252,31 @@ class TestSerialNumberExtraction(TestCase):
|
||||
sn = e("1, 2, 3, 4, 5", 5, 1)
|
||||
self.assertEqual(len(sn), 5)
|
||||
|
||||
# Test partially specifying serials
|
||||
sn = e("1, 2, 4+", 5, 1)
|
||||
self.assertEqual(len(sn), 5)
|
||||
self.assertEqual(sn, [1, 2, 4, 5, 6])
|
||||
|
||||
# Test groups are not interpolated if enough serials are supplied
|
||||
sn = e("1, 2, 3, AF5-69H, 5", 5, 1)
|
||||
self.assertEqual(len(sn), 5)
|
||||
self.assertEqual(sn, [1, 2, 3, "AF5-69H", 5])
|
||||
|
||||
# Test groups are not interpolated with more than one hyphen in a word
|
||||
sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
|
||||
self.assertEqual(len(sn), 5)
|
||||
self.assertEqual(sn, [1, 2, "TG-4SR-92", 4, 5])
|
||||
|
||||
# Test groups are not interpolated with alpha characters
|
||||
sn = e("1, A-2, 3+", 5, 1)
|
||||
self.assertEqual(len(sn), 5)
|
||||
self.assertEqual(sn, [1, "A-2", 3, 4, 5])
|
||||
|
||||
# Test multiple placeholders
|
||||
sn = e("1 2 ~ ~ ~", 5, 3)
|
||||
self.assertEqual(len(sn), 5)
|
||||
self.assertEqual(sn, [1, 2, 3, 4, 5])
|
||||
|
||||
sn = e("1-5, 10-15", 11, 1)
|
||||
self.assertIn(3, sn)
|
||||
self.assertIn(13, sn)
|
||||
@@ -307,6 +332,10 @@ class TestSerialNumberExtraction(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
e("10, a, 7-70j", 4, 1)
|
||||
|
||||
# Test groups are not interpolated with word characters
|
||||
with self.assertRaises(ValidationError):
|
||||
e("1, 2, 3, E-5", 5, 1)
|
||||
|
||||
def test_combinations(self):
|
||||
e = helpers.extract_serial_numbers
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ translated_javascript_urls = [
|
||||
url(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
|
||||
url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
|
||||
url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
|
||||
url(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
|
||||
url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
|
||||
url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
|
||||
url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
""" Version information for InvenTree.
|
||||
"""
|
||||
Version information for InvenTree.
|
||||
Provides information on the current InvenTree version
|
||||
"""
|
||||
|
||||
@@ -8,134 +9,11 @@ import re
|
||||
|
||||
import common.models
|
||||
|
||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 34
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v34 -> 2022-03-25
|
||||
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
|
||||
|
||||
v33 -> 2022-03-24
|
||||
- Adds "plugins_enabled" information to root API endpoint
|
||||
|
||||
v32 -> 2022-03-19
|
||||
- Adds "parameters" detail to Part API endpoint (use ¶meters=true)
|
||||
- Adds ability to filter PartParameterTemplate API by Part instance
|
||||
- Adds ability to filter PartParameterTemplate API by PartCategory instance
|
||||
|
||||
v31 -> 2022-03-14
|
||||
- Adds "updated" field to SupplierPriceBreakList and SupplierPriceBreakDetail API endpoints
|
||||
|
||||
v30 -> 2022-03-09
|
||||
- Adds "exclude_location" field to BuildAutoAllocation API endpoint
|
||||
- Allows BuildItem API endpoint to be filtered by BomItem relation
|
||||
|
||||
v29 -> 2022-03-08
|
||||
- Adds "scheduling" endpoint for predicted stock scheduling information
|
||||
|
||||
v28 -> 2022-03-04
|
||||
- Adds an API endpoint for auto allocation of stock items against a build order
|
||||
- Ref: https://github.com/inventree/InvenTree/pull/2713
|
||||
|
||||
v27 -> 2022-02-28
|
||||
- Adds target_date field to individual line items for purchase orders and sales orders
|
||||
|
||||
v26 -> 2022-02-17
|
||||
- Adds API endpoint for uploading a BOM file and extracting data
|
||||
|
||||
v25 -> 2022-02-17
|
||||
- Adds ability to filter "part" list endpoint by "in_bom_for" argument
|
||||
|
||||
v24 -> 2022-02-10
|
||||
- Adds API endpoint for deleting (cancelling) build order outputs
|
||||
|
||||
v23 -> 2022-02-02
|
||||
- Adds API endpoints for managing plugin classes
|
||||
- Adds API endpoints for managing plugin settings
|
||||
|
||||
v22 -> 2021-12-20
|
||||
- Adds API endpoint to "merge" multiple stock items
|
||||
|
||||
v21 -> 2021-12-04
|
||||
- Adds support for multiple "Shipments" against a SalesOrder
|
||||
- Refactors process for stock allocation against a SalesOrder
|
||||
|
||||
v20 -> 2021-12-03
|
||||
- Adds ability to filter POLineItem endpoint by "base_part"
|
||||
- Adds optional "order_detail" to POLineItem list endpoint
|
||||
|
||||
v19 -> 2021-12-02
|
||||
- Adds the ability to filter the StockItem API by "part_tree"
|
||||
- Returns only stock items which match a particular part.tree_id field
|
||||
|
||||
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
|
||||
|
||||
v16 -> 2021-10-17
|
||||
- Adds API endpoint for completing build order outputs
|
||||
|
||||
v15 -> 2021-10-06
|
||||
- Adds detail endpoint for SalesOrderAllocation model
|
||||
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
|
||||
|
||||
v14 -> 2021-10-05
|
||||
- Stock adjustment actions API is improved, using native DRF serializer support
|
||||
- However adjustment actions now only support 'pk' as a lookup field
|
||||
|
||||
v13 -> 2021-10-05
|
||||
- Adds API endpoint to allocate stock items against a BuildOrder
|
||||
- Updates StockItem API with improved filtering against BomItem data
|
||||
|
||||
v12 -> 2021-09-07
|
||||
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||
|
||||
v11 -> 2021-08-26
|
||||
- Adds "units" field to PartBriefSerializer
|
||||
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
|
||||
|
||||
v10 -> 2021-08-23
|
||||
- Adds "purchase_price_currency" to StockItem serializer
|
||||
- Adds "purchase_price_string" to StockItem serializer
|
||||
- Purchase price is now writable for StockItem serializer
|
||||
|
||||
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
|
||||
|
||||
v7 -> 2021-07-03
|
||||
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
|
||||
- API OPTIONS endpoints provide comprehensive field metedata
|
||||
- Multiple new API endpoints added for database models
|
||||
|
||||
v6 -> 2021-06-23
|
||||
- Part and Company images can now be directly uploaded via the REST API
|
||||
|
||||
v5 -> 2021-06-21
|
||||
- Adds API interface for manufacturer part parameters
|
||||
|
||||
v4 -> 2021-06-01
|
||||
- BOM items can now accept "variant stock" to be assigned against them
|
||||
- Many slight API tweaks were needed to get this to work properly!
|
||||
|
||||
v3 -> 2021-05-22:
|
||||
- The updated StockItem "history tracking" now uses a different interface
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def inventreeInstanceName():
|
||||
""" Returns the InstanceName settings for the current database """
|
||||
@@ -196,7 +74,7 @@ def isInvenTreeUpToDate():
|
||||
and stores it to the database as INVENTREE_LATEST_VERSION
|
||||
"""
|
||||
|
||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', None)
|
||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||
|
||||
# No record for "latest" version - we must assume we are up to date!
|
||||
if not latest:
|
||||
|
||||
@@ -34,8 +34,7 @@ from user_sessions.views import SessionDeleteView, SessionDeleteOtherView
|
||||
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockLocation, StockItem
|
||||
from part.models import PartCategory
|
||||
from common.models import InvenTreeSetting, ColorTheme
|
||||
from users.models import check_user_role, RuleSet
|
||||
|
||||
@@ -882,29 +881,6 @@ class DatabaseStatsView(AjaxView):
|
||||
ajax_template_name = "stats.html"
|
||||
ajax_form_title = _("System Information")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = {}
|
||||
|
||||
# Part stats
|
||||
ctx['part_count'] = Part.objects.count()
|
||||
ctx['part_cat_count'] = PartCategory.objects.count()
|
||||
|
||||
# Stock stats
|
||||
ctx['stock_item_count'] = StockItem.objects.count()
|
||||
ctx['stock_loc_count'] = StockLocation.objects.count()
|
||||
|
||||
"""
|
||||
TODO: Other ideas for database metrics
|
||||
|
||||
- "Popular" parts (used to make other parts?)
|
||||
- Most ordered part
|
||||
- Most sold part
|
||||
- etc etc etc
|
||||
"""
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class NotificationsView(TemplateView):
|
||||
""" View for showing notifications
|
||||
|
||||
@@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
part__in=[p for p in available_parts],
|
||||
)
|
||||
|
||||
# Filter out "serialized" stock items, these cannot be auto-allocated
|
||||
available_stock = available_stock.filter(Q(serial=None) | Q(serial=''))
|
||||
|
||||
if location:
|
||||
# Filter only stock items located "below" the specified location
|
||||
sublocations = location.get_descendants(include_self=True)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% block pre_form_content %}
|
||||
Are you sure you want to delete this build?
|
||||
|
||||
{% trans "Are you sure you want to delete this build?" %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "build/sidebar.html" %}
|
||||
@@ -309,24 +308,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Build Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Build Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if build.notes %}
|
||||
{{ build.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='build-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -392,17 +383,18 @@ onPanelLoad('attachments', function() {
|
||||
});
|
||||
|
||||
onPanelLoad('notes', function() {
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-build-detail" build.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
setupNotesField(
|
||||
'build-notes',
|
||||
'{% url "api-build-detail" build.pk %}',
|
||||
{
|
||||
{% if roles.build.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function reloadTable() {
|
||||
|
||||
+74
-12
@@ -684,7 +684,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
|
||||
'INVENTREE_INSTANCE': {
|
||||
'name': _('InvenTree Instance Name'),
|
||||
'name': _('Server Instance Name'),
|
||||
'default': 'InvenTree server',
|
||||
'description': _('String descriptor for the server instance'),
|
||||
},
|
||||
@@ -696,6 +696,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
},
|
||||
|
||||
'INVENTREE_RESTRICT_ABOUT': {
|
||||
'name': _('Restrict showing `about`'),
|
||||
'description': _('Show the `about` modal only to superusers'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
|
||||
'INVENTREE_COMPANY_NAME': {
|
||||
'name': _('Company name'),
|
||||
'description': _('Internal company name'),
|
||||
@@ -932,6 +939,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'STOCK_BATCH_CODE_TEMPLATE': {
|
||||
'name': _('Batch Code Template'),
|
||||
'description': _('Template for generating default batch codes for stock items'),
|
||||
'default': '',
|
||||
},
|
||||
|
||||
'STOCK_ENABLE_EXPIRY': {
|
||||
'name': _('Stock Expiry'),
|
||||
'description': _('Enable stock expiry functionality'),
|
||||
@@ -1247,6 +1260,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'LABEL_ENABLE': {
|
||||
'name': _('Enable label printing'),
|
||||
'description': _('Enable label printing from the web interface'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
"LABEL_INLINE": {
|
||||
'name': _('Inline label display'),
|
||||
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
|
||||
@@ -1261,20 +1281,62 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_RESULTS': {
|
||||
'name': _('Search Preview Results'),
|
||||
'description': _('Number of results to show in search preview window'),
|
||||
'default': 10,
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'SEARCH_SHOW_STOCK_LEVELS': {
|
||||
'name': _('Search Show Stock'),
|
||||
'description': _('Display stock levels in search preview window'),
|
||||
'SEARCH_PREVIEW_SHOW_PARTS': {
|
||||
'name': _('Search Parts'),
|
||||
'description': _('Display parts in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_CATEGORIES': {
|
||||
'name': _('Search Categories'),
|
||||
'description': _('Display part categories in search preview window'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_STOCK': {
|
||||
'name': _('Search Stock'),
|
||||
'description': _('Display stock items in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_LOCATIONS': {
|
||||
'name': _('Search Locations'),
|
||||
'description': _('Display stock locations in search preview window'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_COMPANIES': {
|
||||
'name': _('Search Companies'),
|
||||
'description': _('Display companies in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': {
|
||||
'name': _('Search Purchase Orders'),
|
||||
'description': _('Display purchase orders in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_SALES_ORDERS': {
|
||||
'name': _('Search Sales Orders'),
|
||||
'description': _('Display sales orders in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_RESULTS': {
|
||||
'name': _('Search Preview Results'),
|
||||
'description': _('Number of results to show in each section of the search preview window'),
|
||||
'default': 10,
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'SEARCH_HIDE_INACTIVE_PARTS': {
|
||||
'name': _("Hide Inactive Parts"),
|
||||
'description': _('Hide inactive parts in search preview window'),
|
||||
@@ -1298,7 +1360,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
|
||||
'STICKY_HEADER': {
|
||||
'name': _('Fixed Navbar'),
|
||||
'description': _('InvenTree navbar position is fixed to the top of the screen'),
|
||||
'description': _('The navbar position is fixed to the top of the screen'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
@@ -63,8 +63,8 @@ class SettingsTest(TestCase):
|
||||
report_test_obj = InvenTreeSetting.get_setting_object('REPORT_ENABLE_TEST_REPORT')
|
||||
|
||||
# check settings base fields
|
||||
self.assertEqual(instance_obj.name, 'InvenTree Instance Name')
|
||||
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'InvenTree Instance Name')
|
||||
self.assertEqual(instance_obj.name, 'Server Instance Name')
|
||||
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'Server Instance Name')
|
||||
self.assertEqual(instance_obj.description, 'String descriptor for the server instance')
|
||||
self.assertEqual(instance_obj.get_setting_description(instance_ref), 'String descriptor for the server instance')
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'company/sidebar.html' %}
|
||||
@@ -181,24 +180,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-company-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Company Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Company Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if company.notes %}
|
||||
{{ company.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='company-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,16 +198,15 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-company-detail" company.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
onPanelLoad('company-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
'company-notes',
|
||||
'{% url "api-company-detail" company.pk %}',
|
||||
{
|
||||
editable: true,
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
loadStockTable($("#assigned-stock-table"), {
|
||||
@@ -230,18 +220,37 @@
|
||||
filterTarget: '#filter-list-customerstock',
|
||||
});
|
||||
|
||||
{% if company.is_customer %}
|
||||
loadSalesOrderTable("#sales-order-table", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
customer: {{ company.id }},
|
||||
}
|
||||
onPanelLoad('company-stock', function() {
|
||||
|
||||
loadStockTable($('#stock-table'), {
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
params: {
|
||||
company: {{ company.id }},
|
||||
part_detail: true,
|
||||
supplier_part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
filterKey: "companystock",
|
||||
});
|
||||
});
|
||||
|
||||
$("#new-sales-order").click(function() {
|
||||
{% if company.is_customer %}
|
||||
onPanelLoad('sales-orders', function() {
|
||||
loadSalesOrderTable("#sales-order-table", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
customer: {{ company.id }},
|
||||
}
|
||||
});
|
||||
|
||||
createSalesOrder({
|
||||
customer: {{ company.pk }},
|
||||
$("#new-sales-order").click(function() {
|
||||
|
||||
createSalesOrder({
|
||||
customer: {{ company.pk }},
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
@@ -270,20 +279,6 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
loadStockTable($('#stock-table'), {
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
params: {
|
||||
company: {{ company.id }},
|
||||
part_detail: true,
|
||||
supplier_part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
filterKey: "companystock",
|
||||
});
|
||||
|
||||
{% if company.is_manufacturer %}
|
||||
|
||||
function reloadManufacturerPartTable() {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "page_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Manufacturer Part" %}
|
||||
{% inventree_title %} | {% trans "Manufacturer Part" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
|
||||
@@ -154,6 +154,14 @@ static_root: '/home/inventree/data/static'
|
||||
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
|
||||
#login_attempts: 5
|
||||
|
||||
# Remote / proxy login
|
||||
# These settings can introduce security problems if configured incorrectly. Please read
|
||||
# https://docs.djangoproject.com/en/4.0/howto/auth-remote-user/ for more details
|
||||
# Use environment variable INVENTREE_REMOTE_LOGIN
|
||||
# remote_login: True
|
||||
# Use environment variable INVENTREE_REMOTE_LOGIN_HEADER
|
||||
# remote_login_header: REMOTE_USER
|
||||
|
||||
# Add new user on first startup
|
||||
#admin_user: admin
|
||||
#admin_email: info@example.com
|
||||
@@ -186,3 +194,12 @@ static_root: '/home/inventree/data/static'
|
||||
# KEYCLOAK_URL: 'https://keycloak.custom/auth'
|
||||
# KEYCLOAK_REALM: 'master'
|
||||
|
||||
# Customization options
|
||||
# Add custom messages to the login page or main interface navbar or exchange the logo
|
||||
# Use environment variable INVENTREE_CUSTOMIZE or INVENTREE_CUSTOM_LOGO
|
||||
# customize:
|
||||
# login_message: InvenTree demo instance - <a href='https://inventree.readthedocs.io/en/latest/demo/'> Click here for login details</a>
|
||||
# navbar_message: <h6>InvenTree demo mode <a href='https://inventree.readthedocs.io/en/latest/demo/'><span class='fas fa-info-circle'></span></a></h6>
|
||||
# logo: logo.png
|
||||
# hide_admin_link: true
|
||||
# hide_password_reset: true
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+1895
-1629
File diff suppressed because it is too large
Load Diff
+2016
-1750
File diff suppressed because it is too large
Load Diff
+1898
-1632
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+2037
-1771
File diff suppressed because it is too large
Load Diff
+1989
-1723
File diff suppressed because it is too large
Load Diff
+1898
-1632
File diff suppressed because it is too large
Load Diff
+2016
-1750
File diff suppressed because it is too large
Load Diff
+1929
-1663
File diff suppressed because it is too large
Load Diff
+2092
-1826
File diff suppressed because it is too large
Load Diff
+2001
-1735
File diff suppressed because it is too large
Load Diff
+2014
-1748
File diff suppressed because it is too large
Load Diff
+1892
-1626
File diff suppressed because it is too large
Load Diff
+2694
-2423
File diff suppressed because it is too large
Load Diff
+2033
-1767
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+2515
-2249
File diff suppressed because it is too large
Load Diff
+1988
-1722
File diff suppressed because it is too large
Load Diff
+2016
-1750
File diff suppressed because it is too large
Load Diff
+2040
-1774
File diff suppressed because it is too large
Load Diff
+2016
-1750
File diff suppressed because it is too large
Load Diff
+1995
-1729
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,14 @@
|
||||
|
||||
{% block page_content %}
|
||||
{% trans "Upload File for Purchase Order" as header_text %}
|
||||
{% order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change as upload_go_ahead %}
|
||||
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
|
||||
{% "panel-upload-file" as panel_id %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text panel_id=panel_id %}
|
||||
{% with "panel-upload-file" as panel_id %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=True error_text=error_text panel_id=panel_id %}
|
||||
{% else %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=False error_text=error_text panel_id=panel_id %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
{% load status_codes %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'order/po_sidebar.html' %}
|
||||
@@ -71,24 +70,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Order Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Order Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if order.notes %}
|
||||
{{ order.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='order-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,16 +89,18 @@
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-po-detail" order.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
onPanelLoad('order-notes', function() {
|
||||
setupNotesField(
|
||||
'order-notes',
|
||||
'{% url "api-po-detail" order.pk %}',
|
||||
{
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
{% load status_codes %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "order/so_sidebar.html" %}
|
||||
@@ -118,24 +117,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Order Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Order Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if order.notes %}
|
||||
{{ order.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='order-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,16 +167,18 @@
|
||||
});
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-so-detail" order.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
onPanelLoad('order-notes', function() {
|
||||
setupNotesField(
|
||||
'order-notes',
|
||||
'{% url "api-so-detail" order.pk %}',
|
||||
{
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
|
||||
+58
-5
@@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
|
||||
ordering = ['level', 'name']
|
||||
|
||||
|
||||
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartSellPriceBreak model
|
||||
"""
|
||||
|
||||
queryset = PartSellPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartSalePriceSerializer
|
||||
|
||||
|
||||
class PartSalePriceList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of PartSalePriceBreak model
|
||||
@@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
|
||||
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartInternalPriceBreak model
|
||||
"""
|
||||
|
||||
queryset = PartInternalPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||
|
||||
|
||||
class PartInternalPriceList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of PartInternalPriceBreak model
|
||||
@@ -798,6 +816,20 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
return queryset
|
||||
|
||||
# unallocated_stock filter
|
||||
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
|
||||
|
||||
def filter_unallocated_stock(self, queryset, name, value):
|
||||
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
queryset = queryset.filter(Q(unallocated_stock__gt=0))
|
||||
else:
|
||||
queryset = queryset.filter(Q(unallocated_stock__lte=0))
|
||||
|
||||
return queryset
|
||||
|
||||
is_template = rest_filters.BooleanFilter()
|
||||
|
||||
assembly = rest_filters.BooleanFilter()
|
||||
@@ -1161,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'variant_of'
|
||||
# Note that this is subtly different from 'ancestor' filter (above)
|
||||
variant_of = params.get('variant_of', None)
|
||||
|
||||
if variant_of is not None:
|
||||
try:
|
||||
template = Part.objects.get(pk=variant_of)
|
||||
variants = template.get_children()
|
||||
queryset = queryset.filter(pk__in=[v.pk for v in variants])
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter only parts which are in the "BOM" for a given part
|
||||
in_bom_for = params.get('in_bom_for', None)
|
||||
|
||||
@@ -1325,15 +1369,12 @@ class PartList(generics.ListCreateAPIView):
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'variant_of',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'name',
|
||||
'creation_date',
|
||||
'IPN',
|
||||
'in_stock',
|
||||
'unallocated_stock',
|
||||
'category',
|
||||
]
|
||||
|
||||
@@ -1587,9 +1628,10 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = BomItem.objects.all()
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||
queryset = self.get_serializer_class().annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1803,6 +1845,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = BomItem.objects.all()
|
||||
serializer_class = part_serializers.BomItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||
queryset = self.get_serializer_class().annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BomItemValidate(generics.UpdateAPIView):
|
||||
""" API endpoint for validating a BomItem """
|
||||
@@ -1887,11 +1938,13 @@ part_api_urls = [
|
||||
|
||||
# Base URL for part sale pricing
|
||||
url(r'^sale-price/', include([
|
||||
url(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
|
||||
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||
])),
|
||||
|
||||
# Base URL for part internal pricing
|
||||
url(r'^internal-price/', include([
|
||||
url(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
|
||||
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
|
||||
])),
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
part: 100
|
||||
sub_part: 1
|
||||
quantity: 10
|
||||
allow_variants: True
|
||||
|
||||
# 40 x R_2K2_0805
|
||||
- model: part.bomitem
|
||||
@@ -38,3 +39,11 @@
|
||||
part: 1
|
||||
sub_part: 5
|
||||
quantity: 3
|
||||
|
||||
# Make "Assembly" from "Bob"
|
||||
- model: part.bomitem
|
||||
pk: 6
|
||||
fields:
|
||||
part: 101
|
||||
sub_part: 100
|
||||
quantity: 10
|
||||
|
||||
@@ -108,6 +108,18 @@
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 101
|
||||
fields:
|
||||
name: 'Assembly'
|
||||
description: 'A high level assembly'
|
||||
salable: true
|
||||
active: True
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# A 'template' part
|
||||
- model: part.part
|
||||
pk: 10000
|
||||
@@ -165,6 +177,7 @@
|
||||
fields:
|
||||
name: 'Green chair variant'
|
||||
variant_of: 10003
|
||||
is_template: true
|
||||
category: 7
|
||||
trackable: true
|
||||
tree_id: 1
|
||||
|
||||
+56
-29
@@ -777,7 +777,8 @@ class Part(MPTTModel):
|
||||
# User can decide whether duplicate IPN (Internal Part Number) values are allowed
|
||||
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN')
|
||||
|
||||
if self.IPN is not None and not allow_duplicate_ipn:
|
||||
# Raise an error if an IPN is set, and it is a duplicate
|
||||
if self.IPN and not allow_duplicate_ipn:
|
||||
parts = Part.objects.filter(IPN__iexact=self.IPN)
|
||||
parts = parts.exclude(pk=self.pk)
|
||||
|
||||
@@ -798,6 +799,10 @@ class Part(MPTTModel):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Strip IPN field
|
||||
if type(self.IPN) is str:
|
||||
self.IPN = self.IPN.strip()
|
||||
|
||||
if self.trackable:
|
||||
for part in self.get_used_in().all():
|
||||
|
||||
@@ -1313,19 +1318,31 @@ class Part(MPTTModel):
|
||||
|
||||
return quantity
|
||||
|
||||
def build_order_allocations(self):
|
||||
def build_order_allocations(self, **kwargs):
|
||||
"""
|
||||
Return all 'BuildItem' objects which allocate this part to Build objects
|
||||
"""
|
||||
|
||||
return BuildModels.BuildItem.objects.filter(stock_item__part__id=self.id)
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
def build_order_allocation_count(self):
|
||||
queryset = BuildModels.BuildItem.objects.all()
|
||||
|
||||
if include_variants:
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(
|
||||
stock_item__part__in=variants,
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(stock_item__part=self)
|
||||
|
||||
return queryset
|
||||
|
||||
def build_order_allocation_count(self, **kwargs):
|
||||
"""
|
||||
Return the total amount of this part allocated to build orders
|
||||
"""
|
||||
|
||||
query = self.build_order_allocations().aggregate(
|
||||
query = self.build_order_allocations(**kwargs).aggregate(
|
||||
total=Coalesce(
|
||||
Sum(
|
||||
'quantity',
|
||||
@@ -1343,9 +1360,22 @@ class Part(MPTTModel):
|
||||
Return all sales-order-allocation objects which allocate this part to a SalesOrder
|
||||
"""
|
||||
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
pending = kwargs.get('pending', None)
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.all()
|
||||
|
||||
if include_variants:
|
||||
# Include allocations for all variants
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(
|
||||
item__part__in=variants,
|
||||
)
|
||||
else:
|
||||
# Only look at this part
|
||||
queryset = queryset.filter(item__part=self)
|
||||
|
||||
# Default behaviour is to only return *pending* allocations
|
||||
pending = kwargs.get('pending', True)
|
||||
|
||||
if pending is True:
|
||||
# Look only for 'open' orders which have not shipped
|
||||
@@ -1380,7 +1410,7 @@ class Part(MPTTModel):
|
||||
|
||||
return query['total']
|
||||
|
||||
def allocation_count(self):
|
||||
def allocation_count(self, **kwargs):
|
||||
"""
|
||||
Return the total quantity of stock allocated for this part,
|
||||
against both build orders and sales orders.
|
||||
@@ -1388,8 +1418,8 @@ class Part(MPTTModel):
|
||||
|
||||
return sum(
|
||||
[
|
||||
self.build_order_allocation_count(),
|
||||
self.sales_order_allocation_count(),
|
||||
self.build_order_allocation_count(**kwargs),
|
||||
self.sales_order_allocation_count(**kwargs),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1433,7 +1463,7 @@ class Part(MPTTModel):
|
||||
- If this part is a "template" (variants exist) then these are counted too
|
||||
"""
|
||||
|
||||
return self.get_stock_count()
|
||||
return self.get_stock_count(include_variants=True)
|
||||
|
||||
def get_bom_item_filter(self, include_inherited=True):
|
||||
"""
|
||||
@@ -2702,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
|
||||
for sub in self.substitutes.all():
|
||||
parts.add(sub.part)
|
||||
|
||||
return parts
|
||||
valid_parts = []
|
||||
|
||||
for p in parts:
|
||||
|
||||
# Inactive parts cannot be 'auto allocated'
|
||||
if not p.active:
|
||||
continue
|
||||
|
||||
# Trackable status must be the same as the sub_part
|
||||
if p.trackable != self.sub_part.trackable:
|
||||
continue
|
||||
|
||||
valid_parts.append(p)
|
||||
|
||||
return valid_parts
|
||||
|
||||
def is_stock_item_valid(self, stock_item):
|
||||
"""
|
||||
@@ -2881,23 +2925,6 @@ class BomItem(models.Model, DataImportMixin):
|
||||
child=self.sub_part.full_name,
|
||||
n=decimal2string(self.quantity))
|
||||
|
||||
def available_stock(self):
|
||||
"""
|
||||
Return the available stock items for the referenced sub_part
|
||||
"""
|
||||
|
||||
query = self.sub_part.stock_items.all()
|
||||
|
||||
query = query.prefetch_related([
|
||||
'sub_part__stock_items',
|
||||
])
|
||||
|
||||
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
|
||||
available=Coalesce(Sum('quantity'), 0)
|
||||
)
|
||||
|
||||
return query['available']
|
||||
|
||||
def get_overage_quantity(self, quantity):
|
||||
""" Calculate overage quantity
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,9 @@ from decimal import Decimal
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models import ExpressionWrapper, F, Q, Func
|
||||
from django.db.models import Subquery, OuterRef, FloatField
|
||||
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@@ -15,6 +17,8 @@ from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from djmoney.contrib.django_rest_framework import MoneyField
|
||||
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
|
||||
from InvenTree.serializers import (DataFileUploadSerializer,
|
||||
DataFileExtractSerializer,
|
||||
InvenTreeAttachmentSerializerField,
|
||||
@@ -24,7 +28,10 @@ from InvenTree.serializers import (DataFileUploadSerializer,
|
||||
InvenTreeAttachmentSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from InvenTree.status_codes import (BuildStatus,
|
||||
PurchaseOrderStatus,
|
||||
SalesOrderStatus)
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, BomItemSubstitute,
|
||||
@@ -143,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
default=currency_code_default,
|
||||
label=_('Currency'),
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -152,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
@@ -167,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
default=currency_code_default,
|
||||
label=_('Currency'),
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -176,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
@@ -305,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
to reduce database trips.
|
||||
"""
|
||||
|
||||
# TODO: Update the "in_stock" annotation to include stock for variants of the part
|
||||
# Ref: https://github.com/inventree/InvenTree/issues/2240
|
||||
|
||||
# Annotate with the total 'in stock' quantity
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(
|
||||
@@ -322,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
stock_item_count=SubqueryCount('stock_items')
|
||||
)
|
||||
|
||||
# Annotate with the total variant stock quantity
|
||||
variant_query = StockItem.objects.filter(
|
||||
part__tree_id=OuterRef('tree_id'),
|
||||
part__lft__gt=OuterRef('lft'),
|
||||
part__rght__lt=OuterRef('rght'),
|
||||
).filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
variant_stock=Coalesce(
|
||||
Subquery(
|
||||
variant_query.annotate(
|
||||
total=Func(F('quantity'), function='SUM', output_field=FloatField())
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter to limit builds to "active"
|
||||
build_filter = Q(
|
||||
status__in=BuildStatus.ACTIVE_CODES
|
||||
@@ -363,6 +401,51 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
),
|
||||
)
|
||||
|
||||
"""
|
||||
Annotate with the number of stock items allocated to sales orders.
|
||||
This annotation is modeled on Part.sales_order_allocations() method:
|
||||
|
||||
- Only look for "open" orders
|
||||
- Stock items have not been "shipped"
|
||||
"""
|
||||
so_allocation_filter = Q(
|
||||
line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order
|
||||
shipment__shipment_date=None, # Allocated item has *not* been shipped out
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
allocated_to_sales_orders=Coalesce(
|
||||
SubquerySum('stock_items__sales_order_allocations__quantity', filter=so_allocation_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
"""
|
||||
Annotate with the number of stock items allocated to build orders.
|
||||
This annotation is modeled on Part.build_order_allocations() method
|
||||
"""
|
||||
bo_allocation_filter = Q(
|
||||
build__status__in=BuildStatus.ACTIVE_CODES,
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
allocated_to_build_orders=Coalesce(
|
||||
SubquerySum('stock_items__allocations__quantity', filter=bo_allocation_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate with the total 'available stock' quantity
|
||||
# This is the current stock, minus any allocations
|
||||
queryset = queryset.annotate(
|
||||
unallocated_stock=ExpressionWrapper(
|
||||
F('in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part):
|
||||
@@ -376,9 +459,13 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||
|
||||
# Calculated fields
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
allocated_to_build_orders = serializers.FloatField(read_only=True)
|
||||
allocated_to_sales_orders = serializers.FloatField(read_only=True)
|
||||
unallocated_stock = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
variant_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -399,7 +486,8 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
partial = True
|
||||
fields = [
|
||||
'active',
|
||||
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'assembly',
|
||||
'category',
|
||||
'category_detail',
|
||||
@@ -411,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'full_name',
|
||||
'image',
|
||||
'in_stock',
|
||||
'variant_stock',
|
||||
'ordering',
|
||||
'building',
|
||||
'IPN',
|
||||
@@ -430,6 +519,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'suppliers',
|
||||
'thumbnail',
|
||||
'trackable',
|
||||
'unallocated_stock',
|
||||
'units',
|
||||
'variant_of',
|
||||
'virtual',
|
||||
@@ -524,6 +614,11 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
purchase_price_range = serializers.SerializerMethodField()
|
||||
|
||||
# Annotated fields for available stock
|
||||
available_stock = serializers.FloatField(read_only=True)
|
||||
available_substitute_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# part_detail and sub_part_detail serializers are only included if requested.
|
||||
# This saves a bunch of database requests
|
||||
@@ -556,10 +651,158 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
queryset = queryset.prefetch_related('sub_part')
|
||||
queryset = queryset.prefetch_related('sub_part__category')
|
||||
queryset = queryset.prefetch_related('sub_part__stock_items')
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'sub_part__stock_items',
|
||||
'sub_part__stock_items__allocations',
|
||||
'sub_part__stock_items__sales_order_allocations',
|
||||
)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'substitutes',
|
||||
'substitutes__part__stock_items',
|
||||
)
|
||||
|
||||
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Annotate the BomItem queryset with extra information:
|
||||
|
||||
Annotations:
|
||||
available_stock: The amount of stock available for the sub_part Part object
|
||||
"""
|
||||
|
||||
"""
|
||||
Construct an "available stock" quantity:
|
||||
available_stock = total_stock - build_order_allocations - sales_order_allocations
|
||||
"""
|
||||
|
||||
build_order_filter = Q(build__status__in=BuildStatus.ACTIVE_CODES)
|
||||
sales_order_filter = Q(
|
||||
line__order__status__in=SalesOrderStatus.OPEN,
|
||||
shipment__shipment_date=None,
|
||||
)
|
||||
|
||||
# Calculate "total stock" for the referenced sub_part
|
||||
# Calculate the "build_order_allocations" for the sub_part
|
||||
# Note that these fields are only aliased, not annotated
|
||||
queryset = queryset.alias(
|
||||
total_stock=Coalesce(
|
||||
SubquerySum(
|
||||
'sub_part__stock_items__quantity',
|
||||
filter=StockItem.IN_STOCK_FILTER
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
allocated_to_sales_orders=Coalesce(
|
||||
SubquerySum(
|
||||
'sub_part__stock_items__sales_order_allocations__quantity',
|
||||
filter=sales_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
allocated_to_build_orders=Coalesce(
|
||||
SubquerySum(
|
||||
'sub_part__stock_items__allocations__quantity',
|
||||
filter=build_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_stock' based on previously annotated fields
|
||||
queryset = queryset.annotate(
|
||||
available_stock=ExpressionWrapper(
|
||||
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
queryset = queryset.alias(
|
||||
substitute_stock=Coalesce(
|
||||
SubquerySum(
|
||||
'substitutes__part__stock_items__quantity',
|
||||
filter=StockItem.IN_STOCK_FILTER,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
substitute_build_allocations=Coalesce(
|
||||
SubquerySum(
|
||||
'substitutes__part__stock_items__allocations__quantity',
|
||||
filter=build_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
substitute_sales_allocations=Coalesce(
|
||||
SubquerySum(
|
||||
'substitutes__part__stock_items__sales_order_allocations__quantity',
|
||||
filter=sales_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_substitute_stock' field
|
||||
queryset = queryset.annotate(
|
||||
available_substitute_stock=ExpressionWrapper(
|
||||
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate the queryset with 'available variant stock' information
|
||||
variant_stock_query = StockItem.objects.filter(
|
||||
part__tree_id=OuterRef('sub_part__tree_id'),
|
||||
part__lft__gt=OuterRef('sub_part__lft'),
|
||||
part__rght__lt=OuterRef('sub_part__rght'),
|
||||
).filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
queryset = queryset.alias(
|
||||
variant_stock_total=Coalesce(
|
||||
Subquery(
|
||||
variant_stock_query.annotate(
|
||||
total=Func(F('quantity'), function='SUM', output_field=FloatField())
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField()
|
||||
),
|
||||
variant_stock_build_order_allocations=Coalesce(
|
||||
Subquery(
|
||||
variant_stock_query.annotate(
|
||||
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
),
|
||||
variant_stock_sales_order_allocations=Coalesce(
|
||||
Subquery(
|
||||
variant_stock_query.annotate(
|
||||
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
available_variant_stock=ExpressionWrapper(
|
||||
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_purchase_price_range(self, obj):
|
||||
""" Return purchase price range """
|
||||
|
||||
@@ -629,6 +872,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'substitutes',
|
||||
'price_range',
|
||||
'validated',
|
||||
|
||||
# Annotated fields describing available quantity
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
'available_variant_stock',
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="markdownx row">
|
||||
<div class="markdown col-md-6">
|
||||
{% include 'django/forms/widgets/textarea.html' %}
|
||||
</div>
|
||||
<div class="markdown col-md-6">
|
||||
<div class="markdownx-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,6 +187,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-stock'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Stock Items" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-parameters'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Part Parameters" %}</h4>
|
||||
@@ -223,6 +232,21 @@
|
||||
{{ block.super }}
|
||||
|
||||
{% if category %}
|
||||
|
||||
onPanelLoad('stock', function() {
|
||||
loadStockTable(
|
||||
$('#stock-table'),
|
||||
{
|
||||
params: {
|
||||
category: {{ category.pk }},
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
supplier_part_detail: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad('parameters', function() {
|
||||
loadParametricPartTable(
|
||||
"#parametric-part-table",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
{% include "sidebar_link.html" with url=url text=text icon="fa-file-upload" %}
|
||||
{% endif %}
|
||||
{% if category %}
|
||||
{% trans "Stock Items" as text %}
|
||||
{% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %}
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
||||
@@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'part/part_sidebar.html' %}
|
||||
@@ -125,8 +124,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
||||
{% if show_price_history %}
|
||||
{% if part.purchaseable or part.salable %}
|
||||
<div class='panel panel-hidden' id='panel-pricing'>
|
||||
{% include "part/prices.html" %}
|
||||
</div>
|
||||
@@ -134,24 +132,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-part-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Part Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if part.notes %}
|
||||
{{ part.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='part-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +409,18 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// Load the "notes" tab
|
||||
onPanelLoad('part-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
'part-notes',
|
||||
'{% url "api-part-detail" part.pk %}',
|
||||
{
|
||||
editable: {% if roles.part.change %}true{% else %}false{% endif %},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Load the "scheduling" tab
|
||||
onPanelLoad('scheduling', function() {
|
||||
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
|
||||
@@ -832,36 +834,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-part-detail" part.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Part Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$(".slidey").change(function() {
|
||||
var field = $(this).attr('fieldname');
|
||||
|
||||
var checked = $(this).prop('checked');
|
||||
|
||||
var data = {};
|
||||
|
||||
data[field] = checked;
|
||||
// Update the particular field
|
||||
inventreePut("{% url 'api-part-detail' part.id %}",
|
||||
data,
|
||||
{
|
||||
method: 'PATCH',
|
||||
reloadOnSuccess: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad("part-parameters", function() {
|
||||
loadPartParameterTable(
|
||||
'#parameter-table',
|
||||
@@ -1036,7 +1008,7 @@
|
||||
pb_url_slug: 'internal-price',
|
||||
pb_url: '{% url 'api-part-internal-price-list' %}',
|
||||
pb_new_btn: $('#new-internal-price-break'),
|
||||
pb_new_url: '{% url 'internal-price-break-create' %}',
|
||||
pb_new_url: '{% url 'api-part-internal-price-list' %}',
|
||||
linkedGraph: $('#InternalPriceBreakChart'),
|
||||
},
|
||||
);
|
||||
@@ -1052,7 +1024,7 @@
|
||||
pb_url_slug: 'sale-price',
|
||||
pb_url: "{% url 'api-part-sale-price-list' %}",
|
||||
pb_new_btn: $('#new-price-break'),
|
||||
pb_new_url: '{% url 'sale-price-break-create' %}',
|
||||
pb_new_url: '{% url 'api-part-sale-price-list' %}',
|
||||
linkedGraph: $('#SalePriceBreakChart'),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
|
||||
{% block content %}
|
||||
{% trans "Import Parts from File" as header_text %}
|
||||
{% roles.part.change as upload_go_ahead %}
|
||||
{% trans "Unsuffitient privileges." as error_text %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text %}
|
||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=roles.part.change error_text=error_text %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
@@ -37,13 +37,17 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if barcodes %}
|
||||
{% if barcodes or labels_enabled %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
{% if barcodes %}
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% endif %}
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -207,48 +211,21 @@
|
||||
{% if part.component %}
|
||||
{% if required_build_order_quantity > 0 %}
|
||||
<tr>
|
||||
<td><span class='fas fa-clipboard-list'></span></td>
|
||||
<td>{% trans "Required for Build Orders" %}</td>
|
||||
<td>{% decimal required_build_order_quantity %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-dolly'></span></td>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
<td>{% trans "Allocated to Build Orders" %}</td>
|
||||
<td>
|
||||
{% decimal allocated_build_order_quantity %}
|
||||
{% if allocated_build_order_quantity < required_build_order_quantity %}
|
||||
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
|
||||
{% else %}
|
||||
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% progress_bar allocated_build_order_quantity required_build_order_quantity id='build-order-allocated' max_width='150px' %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if part.salable %}
|
||||
{% if required_sales_order_quantity > 0 %}
|
||||
<tr>
|
||||
<td><span class='fas fa-clipboard-list'></span></td>
|
||||
<td>{% trans "Required for Sales Orders" %}</td>
|
||||
<td>
|
||||
{% decimal required_sales_order_quantity %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-dolly'></span></td>
|
||||
<td><span class='fas fa-truck'></span></td>
|
||||
<td>{% trans "Allocated to Sales Orders" %}</td>
|
||||
<td>
|
||||
{% decimal allocated_sales_order_quantity %}
|
||||
{% if allocated_sales_order_quantity < required_sales_order_quantity %}
|
||||
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
|
||||
{% else %}
|
||||
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% progress_bar allocated_sales_order_quantity required_sales_order_quantity id='sales-order-allocated' max_width='150px' %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not part.is_template %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
@@ -262,7 +239,6 @@
|
||||
<td>{% decimal quantity_being_built %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock details_right %}
|
||||
@@ -424,9 +400,11 @@
|
||||
);
|
||||
});
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function adjustPartStock(action) {
|
||||
inventreeGet(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
||||
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
||||
@@ -28,7 +27,7 @@
|
||||
{% trans "Used In" as text %}
|
||||
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
||||
{% endif %}
|
||||
{% if show_price_history %}
|
||||
{% if part.purchaseable or part.salable %}
|
||||
{% trans "Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
||||
{% endif %}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
{% load crispy_forms_tags %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
{% if show_price_history %}
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Pricing Information" %}</h4>
|
||||
</div>
|
||||
@@ -43,7 +46,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.bom_count > 0 %}
|
||||
{% if part.assembly and part.bom_count > 0 %}
|
||||
{% if min_total_bom_price %}
|
||||
<tr>
|
||||
<td><strong>{% trans 'BOM Pricing' %}</strong>
|
||||
@@ -147,7 +150,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
<a class="anchor" id="supplier-cost"></a>
|
||||
@@ -170,7 +173,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if price_history %}
|
||||
{% if show_price_history %}
|
||||
<a class="anchor" id="purchase-price"></a>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Purchase Price" %}
|
||||
@@ -279,6 +282,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_price_history %}
|
||||
<a class="anchor" id="sale-price"></a>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Sale Price" %}
|
||||
@@ -298,3 +302,5 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -18,7 +18,8 @@ from django.conf import settings as djangosettings
|
||||
from django import template
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.templatetags.static import StaticNode
|
||||
from django.templatetags.static import StaticNode, static
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from InvenTree import version, settings
|
||||
|
||||
@@ -160,10 +161,11 @@ def inventree_in_debug_mode(*args, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_demo_mode(*args, **kwargs):
|
||||
""" Return True if the server is running in DEMO mode """
|
||||
|
||||
return djangosettings.DEMO_MODE
|
||||
def inventree_show_about(user, *args, **kwargs):
|
||||
""" Return True if the about modal should be shown """
|
||||
if InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT') and not user.is_superuser:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
@@ -220,8 +222,13 @@ def python_version(*args, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_version(*args, **kwargs):
|
||||
def inventree_version(shortstring=False, *args, **kwargs):
|
||||
""" Return InvenTree version string """
|
||||
if shortstring:
|
||||
return _("{title} v{version}".format(
|
||||
title=version.inventreeInstanceTitle(),
|
||||
version=version.inventreeVersion()
|
||||
))
|
||||
return version.inventreeVersion()
|
||||
|
||||
|
||||
@@ -352,21 +359,24 @@ def visible_global_settings(*args, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def progress_bar(val, max, *args, **kwargs):
|
||||
def progress_bar(val, max_val, *args, **kwargs):
|
||||
"""
|
||||
Render a progress bar element
|
||||
"""
|
||||
|
||||
item_id = kwargs.get('id', 'progress-bar')
|
||||
|
||||
if val > max:
|
||||
val = InvenTree.helpers.normalize(val)
|
||||
max_val = InvenTree.helpers.normalize(max_val)
|
||||
|
||||
if val > max_val:
|
||||
style = 'progress-bar-over'
|
||||
elif val < max:
|
||||
elif val < max_val:
|
||||
style = 'progress-bar-under'
|
||||
else:
|
||||
style = ''
|
||||
|
||||
percent = float(val / max) * 100
|
||||
percent = float(val / max_val) * 100
|
||||
|
||||
if percent > 100:
|
||||
percent = 100
|
||||
@@ -383,7 +393,7 @@ def progress_bar(val, max, *args, **kwargs):
|
||||
html = f"""
|
||||
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
|
||||
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
|
||||
<div class='progress-value'>{val} / {max}</div>
|
||||
<div class='progress-value'>{val} / {max_val}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -509,6 +519,22 @@ def mail_configured():
|
||||
return bool(settings.EMAIL_HOST)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_customize(reference, *args, **kwargs):
|
||||
""" Return customization values for the user interface """
|
||||
|
||||
return djangosettings.CUSTOMIZE.get(reference, '')
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_logo(*args, **kwargs):
|
||||
""" Return the path to the logo-file """
|
||||
|
||||
if settings.CUSTOM_LOGO:
|
||||
return default_storage.url(settings.CUSTOM_LOGO)
|
||||
return static('img/inventree.png')
|
||||
|
||||
|
||||
class I18nStaticNode(StaticNode):
|
||||
"""
|
||||
custom StaticNode
|
||||
|
||||
+454
-2
@@ -9,7 +9,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from part.models import BomItem, BomItemSubstitute
|
||||
@@ -17,6 +17,9 @@ from stock.models import StockItem, StockLocation
|
||||
from company.models import Company
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
import build.models
|
||||
import order.models
|
||||
|
||||
|
||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@@ -247,7 +250,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
data = {'cascade': True}
|
||||
response = self.client.get(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 13)
|
||||
self.assertEqual(len(response.data), Part.objects.count())
|
||||
|
||||
def test_get_parts_by_cat(self):
|
||||
url = reverse('api-part-list')
|
||||
@@ -564,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['name'], name)
|
||||
self.assertEqual(response.data['description'], description)
|
||||
|
||||
def test_template_filters(self):
|
||||
"""
|
||||
Unit tests for API filters related to template parts:
|
||||
|
||||
- variant_of : Return children of specified part
|
||||
- ancestor : Return descendants of specified part
|
||||
|
||||
Uses the 'chair template' part (pk=10000)
|
||||
"""
|
||||
|
||||
# Rebuild the MPTT structure before running these tests
|
||||
Part.objects.rebuild()
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'variant_of': 10000,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# 3 direct children of template part
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10000,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 4 total descendants
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Use the 'green chair' as our reference
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'variant_of': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Add some more variants
|
||||
|
||||
p = Part.objects.get(pk=10004)
|
||||
|
||||
for i in range(100):
|
||||
Part.objects.create(
|
||||
name=f'Chair variant {i}',
|
||||
description='A new chair variant',
|
||||
variant_of=p,
|
||||
)
|
||||
|
||||
# There should still be only one direct variant
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'variant_of': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# However, now should be 101 descendants
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 101)
|
||||
|
||||
def test_variant_stock(self):
|
||||
"""
|
||||
Unit tests for the 'variant_stock' annotation,
|
||||
which provides a stock count for *variant* parts
|
||||
"""
|
||||
|
||||
# Ensure the MPTT structure is in a known state before running tests
|
||||
Part.objects.rebuild()
|
||||
|
||||
# Initially, there are no "chairs" in stock,
|
||||
# so each 'chair' template should report variant_stock=0
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Look at the "detail" URL for the master chair template
|
||||
response = self.get('/api/part/10000/', {}, expected_code=200)
|
||||
|
||||
# This part should report 'zero' as variant stock
|
||||
self.assertEqual(response.data['variant_stock'], 0)
|
||||
|
||||
# Grab a list of all variant chairs *under* the master template
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10000,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 4 total descendants
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
for variant in response.data:
|
||||
self.assertEqual(variant['variant_stock'], 0)
|
||||
|
||||
# Now, let's make some variant stock
|
||||
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
|
||||
StockItem.objects.create(
|
||||
part=variant,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
response = self.get('/api/part/10000/', {}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['in_stock'], 0)
|
||||
self.assertEqual(response.data['variant_stock'], 400)
|
||||
|
||||
# Check that each variant reports the correct stock quantities
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10000,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
expected_variant_stock = {
|
||||
10001: 0,
|
||||
10002: 0,
|
||||
10003: 100,
|
||||
10004: 0,
|
||||
}
|
||||
|
||||
for variant in response.data:
|
||||
self.assertEqual(variant['in_stock'], 100)
|
||||
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
|
||||
|
||||
# Add some 'sub variants' for the green chair variant
|
||||
green_chair = Part.objects.get(pk=10004)
|
||||
|
||||
for i in range(10):
|
||||
gcv = Part.objects.create(
|
||||
name=f"GC Var {i}",
|
||||
description="Green chair variant",
|
||||
variant_of=green_chair,
|
||||
)
|
||||
|
||||
StockItem.objects.create(
|
||||
part=gcv,
|
||||
quantity=50,
|
||||
)
|
||||
|
||||
# Spot check of some values
|
||||
response = self.get('/api/part/10000/', {})
|
||||
self.assertEqual(response.data['variant_stock'], 900)
|
||||
|
||||
response = self.get('/api/part/10004/', {})
|
||||
self.assertEqual(response.data['variant_stock'], 500)
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
@@ -575,7 +757,12 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'test_templates',
|
||||
'manufacturer_part',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@@ -802,6 +989,38 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
def test_details(self):
|
||||
"""
|
||||
Test that the required details are available
|
||||
"""
|
||||
|
||||
p = Part.objects.get(pk=1)
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': 1})
|
||||
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
# How many parts are 'on order' for this part?
|
||||
lines = order.models.PurchaseOrderLineItem.objects.filter(
|
||||
part__part__pk=1,
|
||||
order__status__in=PurchaseOrderStatus.OPEN,
|
||||
)
|
||||
|
||||
on_order = 0
|
||||
|
||||
# Calculate the "on_order" quantity by hand,
|
||||
# to check it matches the API value
|
||||
for line in lines:
|
||||
on_order += line.quantity
|
||||
on_order -= line.received
|
||||
|
||||
self.assertEqual(on_order, data['ordering'])
|
||||
self.assertEqual(on_order, p.on_order)
|
||||
|
||||
# Some other checks
|
||||
self.assertEqual(data['in_stock'], 9000)
|
||||
self.assertEqual(data['unallocated_stock'], 9000)
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@@ -815,6 +1034,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
'build',
|
||||
'location',
|
||||
'stock',
|
||||
'sales_order',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@@ -826,6 +1049,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Ensure the part "variant" tree is correctly structured
|
||||
Part.objects.rebuild()
|
||||
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
@@ -880,6 +1106,153 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['in_stock'], 1100)
|
||||
self.assertEqual(data['stock_item_count'], 105)
|
||||
|
||||
def test_allocation_annotations(self):
|
||||
"""
|
||||
Tests for query annotations which add allocation information.
|
||||
Ref: https://github.com/inventree/InvenTree/pull/2797
|
||||
"""
|
||||
|
||||
# We are looking at Part ID 100 ("Bob")
|
||||
url = reverse('api-part-detail', kwargs={'pk': 100})
|
||||
|
||||
part = Part.objects.get(pk=100)
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
# Check that the expected annotated fields exist in the data
|
||||
data = response.data
|
||||
self.assertEqual(data['allocated_to_build_orders'], 0)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 0)
|
||||
|
||||
# The unallocated stock count should equal the 'in stock' coutn
|
||||
in_stock = data['in_stock']
|
||||
self.assertEqual(in_stock, 126)
|
||||
self.assertEqual(data['unallocated_stock'], in_stock)
|
||||
|
||||
# Check that model functions return the same values
|
||||
self.assertEqual(part.build_order_allocation_count(), 0)
|
||||
self.assertEqual(part.sales_order_allocation_count(), 0)
|
||||
self.assertEqual(part.total_stock, in_stock)
|
||||
self.assertEqual(part.available_stock, in_stock)
|
||||
|
||||
# Now, let's create a sales order, and allocate some stock
|
||||
so = order.models.SalesOrder.objects.create(
|
||||
reference='001',
|
||||
customer=Company.objects.get(pk=1),
|
||||
)
|
||||
|
||||
# We wish to send 50 units of "Bob" against this sales order
|
||||
line = order.models.SalesOrderLineItem.objects.create(
|
||||
quantity=50,
|
||||
order=so,
|
||||
part=part,
|
||||
)
|
||||
|
||||
# Create a shipment against the order
|
||||
shipment_1 = order.models.SalesOrderShipment.objects.create(
|
||||
order=so,
|
||||
reference='001',
|
||||
)
|
||||
|
||||
shipment_2 = order.models.SalesOrderShipment.objects.create(
|
||||
order=so,
|
||||
reference='002',
|
||||
)
|
||||
|
||||
# Allocate stock items to this order, against multiple shipments
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line,
|
||||
shipment=shipment_1,
|
||||
item=StockItem.objects.get(pk=1007),
|
||||
quantity=17
|
||||
)
|
||||
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line,
|
||||
shipment=shipment_1,
|
||||
item=StockItem.objects.get(pk=1008),
|
||||
quantity=18
|
||||
)
|
||||
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line,
|
||||
shipment=shipment_2,
|
||||
item=StockItem.objects.get(pk=1006),
|
||||
quantity=15,
|
||||
)
|
||||
|
||||
# Submit the API request again - should show us the sales order allocation
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 50)
|
||||
self.assertEqual(data['in_stock'], 126)
|
||||
self.assertEqual(data['unallocated_stock'], 76)
|
||||
|
||||
# Now, "ship" the first shipment (so the stock is not 'in stock' any more)
|
||||
shipment_1.complete_shipment(None)
|
||||
|
||||
# Refresh the API data
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_build_orders'], 0)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 15)
|
||||
self.assertEqual(data['in_stock'], 91)
|
||||
self.assertEqual(data['unallocated_stock'], 76)
|
||||
|
||||
# Next, we create a build order and allocate stock against it
|
||||
bo = build.models.Build.objects.create(
|
||||
part=Part.objects.get(pk=101),
|
||||
quantity=10,
|
||||
title='Making some assemblies',
|
||||
status=BuildStatus.PRODUCTION,
|
||||
)
|
||||
|
||||
bom_item = BomItem.objects.get(pk=6)
|
||||
|
||||
# Allocate multiple stock items against this build order
|
||||
build.models.BuildItem.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
stock_item=StockItem.objects.get(pk=1000),
|
||||
quantity=10,
|
||||
)
|
||||
|
||||
# Request data once more
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_build_orders'], 10)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 15)
|
||||
self.assertEqual(data['in_stock'], 91)
|
||||
self.assertEqual(data['unallocated_stock'], 66)
|
||||
|
||||
# Again, check that the direct model functions return the same values
|
||||
self.assertEqual(part.build_order_allocation_count(), 10)
|
||||
self.assertEqual(part.sales_order_allocation_count(), 15)
|
||||
self.assertEqual(part.total_stock, 91)
|
||||
self.assertEqual(part.available_stock, 66)
|
||||
|
||||
# Allocate further stock against the build
|
||||
build.models.BuildItem.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
stock_item=StockItem.objects.get(pk=1001),
|
||||
quantity=10,
|
||||
)
|
||||
|
||||
# Request data once more
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_build_orders'], 20)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 15)
|
||||
self.assertEqual(data['in_stock'], 91)
|
||||
self.assertEqual(data['unallocated_stock'], 56)
|
||||
|
||||
# Again, check that the direct model functions return the same values
|
||||
self.assertEqual(part.build_order_allocation_count(), 20)
|
||||
self.assertEqual(part.sales_order_allocation_count(), 15)
|
||||
self.assertEqual(part.total_stock, 91)
|
||||
self.assertEqual(part.available_stock, 56)
|
||||
|
||||
|
||||
class BomItemTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@@ -966,6 +1339,12 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], bom_item.pk)
|
||||
|
||||
# Each item in response should contain expected keys
|
||||
for el in response.data:
|
||||
|
||||
for key in ['available_stock', 'available_substitute_stock']:
|
||||
self.assertTrue(key in el)
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
"""
|
||||
Get the detail view for a single BomItem object
|
||||
@@ -975,6 +1354,26 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
expected_values = [
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
'overage',
|
||||
'pk',
|
||||
'part',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
'substitutes',
|
||||
'validated',
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
]
|
||||
|
||||
for key in expected_values:
|
||||
self.assertTrue(key in response.data)
|
||||
|
||||
self.assertEqual(int(float(response.data['quantity'])), 25)
|
||||
|
||||
# Increase the quantity
|
||||
@@ -1162,6 +1561,21 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# The BomItem detail endpoint should now also reflect the substitute data
|
||||
data = self.get(
|
||||
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
|
||||
expected_code=200
|
||||
).data
|
||||
|
||||
# 5 substitute parts
|
||||
self.assertEqual(len(data['substitutes']), 5)
|
||||
|
||||
# 5 x 1,000 stock quantity
|
||||
self.assertEqual(data['available_substitute_stock'], 5000)
|
||||
|
||||
# 9,000 stock directly available
|
||||
self.assertEqual(data['available_stock'], 9000)
|
||||
|
||||
def test_bom_item_uses(self):
|
||||
"""
|
||||
Tests for the 'uses' field
|
||||
@@ -1215,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(response.data), i)
|
||||
|
||||
def test_bom_variant_stock(self):
|
||||
"""
|
||||
Test for 'available_variant_stock' annotation
|
||||
"""
|
||||
|
||||
Part.objects.rebuild()
|
||||
|
||||
# BOM item we are interested in
|
||||
bom_item = BomItem.objects.get(pk=1)
|
||||
|
||||
response = self.get('/api/bom/1/', {}, expected_code=200)
|
||||
|
||||
# Initially, no variant stock available
|
||||
self.assertEqual(response.data['available_variant_stock'], 0)
|
||||
|
||||
# Create some 'variants' of the referenced sub_part
|
||||
bom_item.sub_part.is_template = True
|
||||
bom_item.sub_part.save()
|
||||
|
||||
for i in range(10):
|
||||
# Create a variant part
|
||||
vp = Part.objects.create(
|
||||
name=f"Var {i}",
|
||||
description="Variant part",
|
||||
variant_of=bom_item.sub_part,
|
||||
)
|
||||
|
||||
# Create a stock item
|
||||
StockItem.objects.create(
|
||||
part=vp,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
# There should now be variant stock available
|
||||
response = self.get('/api/bom/1/', {}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['available_variant_stock'], 1000)
|
||||
|
||||
|
||||
class PartParameterTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
||||
@@ -46,7 +46,7 @@ class BomItemTest(TestCase):
|
||||
# TODO: Tests for multi-level BOMs
|
||||
|
||||
def test_used_in(self):
|
||||
self.assertEqual(self.bob.used_in_count, 0)
|
||||
self.assertEqual(self.bob.used_in_count, 1)
|
||||
self.assertEqual(self.orphan.used_in_count, 1)
|
||||
|
||||
def test_self_reference(self):
|
||||
|
||||
@@ -349,6 +349,26 @@ class PartSettingsTest(TestCase):
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||
part.full_clean()
|
||||
|
||||
# Any duplicate IPN should raise an error
|
||||
Part.objects.create(name='xyz', revision='1', description='A part', IPN='UNIQUE')
|
||||
|
||||
# Case insensitive, so variations on spelling should throw an error
|
||||
for ipn in ['UNiquE', 'uniQuE', 'unique']:
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='xyz', revision='2', description='A part', IPN=ipn)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='zyx', description='A part', IPN='UNIQUE')
|
||||
|
||||
# However, *blank* / empty IPN values should be allowed, even if duplicates are not
|
||||
# Note that leading / trailling whitespace characters are trimmed, too
|
||||
Part.objects.create(name='abc', revision='1', description='A part', IPN=None)
|
||||
Part.objects.create(name='abc', revision='2', description='A part', IPN='')
|
||||
Part.objects.create(name='abc', revision='3', description='A part', IPN=None)
|
||||
Part.objects.create(name='abc', revision='4', description='A part', IPN=' ')
|
||||
Part.objects.create(name='abc', revision='5', description='A part', IPN=' ')
|
||||
Part.objects.create(name='abc', revision='6', description='A part', IPN=' ')
|
||||
|
||||
|
||||
class PartSubscriptionTests(TestCase):
|
||||
|
||||
|
||||
@@ -13,18 +13,6 @@ from django.conf.urls import url, include
|
||||
from . import views
|
||||
|
||||
|
||||
sale_price_break_urls = [
|
||||
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
|
||||
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
|
||||
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
|
||||
]
|
||||
|
||||
internal_price_break_urls = [
|
||||
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
|
||||
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
|
||||
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
|
||||
]
|
||||
|
||||
part_parameter_urls = [
|
||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
||||
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||
@@ -86,12 +74,6 @@ part_urls = [
|
||||
# Part category
|
||||
url(r'^category/', include(category_urls)),
|
||||
|
||||
# Part price breaks
|
||||
url(r'^sale-price/', include(sale_price_break_urls)),
|
||||
|
||||
# Part internal price breaks
|
||||
url(r'^internal-price/', include(internal_price_break_urls)),
|
||||
|
||||
# Part parameters
|
||||
url(r'^parameter/', include(part_parameter_urls)),
|
||||
|
||||
|
||||
+5
-102
@@ -18,7 +18,6 @@ from django.forms import HiddenInput
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
@@ -33,7 +32,6 @@ from decimal import Decimal
|
||||
from .models import PartCategory, Part
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
@@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
context.update(**ctx)
|
||||
|
||||
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
|
||||
|
||||
context['show_price_history'] = show_price_history
|
||||
|
||||
# Pricing information
|
||||
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
|
||||
if show_price_history:
|
||||
ctx = self.get_pricing(self.get_quantity())
|
||||
ctx['form'] = self.form_class(initial=self.get_initials())
|
||||
|
||||
@@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
||||
return None
|
||||
|
||||
return self.object
|
||||
|
||||
|
||||
class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a sale price break for a part
|
||||
"""
|
||||
|
||||
model = PartSellPriceBreak
|
||||
form_class = part_forms.EditPartSalePriceBreakForm
|
||||
ajax_form_title = _('Add Price Break')
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Added new price break')
|
||||
}
|
||||
|
||||
def get_part(self):
|
||||
try:
|
||||
part = Part.objects.get(id=self.request.GET.get('part'))
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
if part is None:
|
||||
try:
|
||||
part = Part.objects.get(id=self.request.POST.get('part'))
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
return part
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super(AjaxCreateView, self).get_initial()
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
default_currency = inventree_settings.currency_code_default()
|
||||
currency = CURRENCIES.get(default_currency, None)
|
||||
|
||||
if currency is not None:
|
||||
initials['price'] = [1.0, currency]
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class PartSalePriceBreakEdit(AjaxUpdateView):
|
||||
""" View for editing a sale price break """
|
||||
|
||||
model = PartSellPriceBreak
|
||||
form_class = part_forms.EditPartSalePriceBreakForm
|
||||
ajax_form_title = _('Edit Price Break')
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class PartSalePriceBreakDelete(AjaxDeleteView):
|
||||
""" View for deleting a sale price break """
|
||||
|
||||
model = PartSellPriceBreak
|
||||
ajax_form_title = _("Delete Price Break")
|
||||
ajax_template_name = "modal_delete_form.html"
|
||||
|
||||
|
||||
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
|
||||
""" View for creating a internal price break for a part """
|
||||
|
||||
model = PartInternalPriceBreak
|
||||
form_class = part_forms.EditPartInternalPriceBreakForm
|
||||
ajax_form_title = _('Add Internal Price Break')
|
||||
permission_required = 'roles.sales_order.add'
|
||||
|
||||
|
||||
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
|
||||
""" View for editing a internal price break """
|
||||
|
||||
model = PartInternalPriceBreak
|
||||
form_class = part_forms.EditPartInternalPriceBreakForm
|
||||
ajax_form_title = _('Edit Internal Price Break')
|
||||
permission_required = 'roles.sales_order.change'
|
||||
|
||||
|
||||
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
|
||||
""" View for deleting a internal price break """
|
||||
|
||||
model = PartInternalPriceBreak
|
||||
ajax_form_title = _("Delete Internal Price Break")
|
||||
permission_required = 'roles.sales_order.delete'
|
||||
|
||||
@@ -504,10 +504,10 @@ class APICallMixin:
|
||||
|
||||
@property
|
||||
def api_headers(self):
|
||||
return {
|
||||
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if getattr(self, 'API_TOKEN_SETTING'):
|
||||
headers[self.API_TOKEN] = self.get_setting(self.API_TOKEN_SETTING)
|
||||
return headers
|
||||
|
||||
def api_build_url_args(self, arguments):
|
||||
groups = []
|
||||
@@ -515,16 +515,21 @@ class APICallMixin:
|
||||
groups.append(f'{key}={",".join([str(a) for a in val])}')
|
||||
return f'?{"&".join(groups)}'
|
||||
|
||||
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True):
|
||||
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True, endpoint_is_url: bool = False):
|
||||
if url_args:
|
||||
endpoint += self.api_build_url_args(url_args)
|
||||
|
||||
if headers is None:
|
||||
headers = self.api_headers
|
||||
|
||||
if endpoint_is_url:
|
||||
url = endpoint
|
||||
else:
|
||||
url = f'{self.api_url}/{endpoint}'
|
||||
|
||||
# build kwargs for call
|
||||
kwargs = {
|
||||
'url': f'{self.api_url}/{endpoint}',
|
||||
'url': url,
|
||||
'headers': headers,
|
||||
}
|
||||
if data:
|
||||
|
||||
@@ -94,6 +94,14 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
"""
|
||||
return getattr(self, 'is_package', False)
|
||||
|
||||
@property
|
||||
def is_sample(self):
|
||||
"""
|
||||
Is this plugin part of the samples?
|
||||
"""
|
||||
path = str(self.package_path)
|
||||
return path.startswith('plugin/samples/')
|
||||
|
||||
# region properties
|
||||
@property
|
||||
def slug(self):
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
{% load report %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block page_margin %}
|
||||
margin: 2cm;
|
||||
@@ -16,7 +15,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
|
||||
{% endblock %}
|
||||
|
||||
{% block bottom_center %}
|
||||
content: "InvenTree v{% inventree_version %}";
|
||||
content: "{% inventree_version shortstring=True %}";
|
||||
{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
|
||||
@@ -16,7 +16,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
|
||||
{% endblock %}
|
||||
|
||||
{% block bottom_center %}
|
||||
content: "InvenTree v{% inventree_version %}";
|
||||
content: "{% inventree_version shortstring=True %}";
|
||||
{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
|
||||
@@ -14,7 +14,7 @@ content: "{{ date.isoformat }}";
|
||||
{% endblock %}
|
||||
|
||||
{% block bottom_center %}
|
||||
content: "InvenTree v{% inventree_version %}";
|
||||
content: "{% inventree_version shortstring=True %}";
|
||||
{% endblock %}
|
||||
|
||||
{% block top_center %}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
This script is used to simplify the translation process.
|
||||
|
||||
Django provides a framework for working out which strings are "translatable",
|
||||
and these strings are then dumped in a file under InvenTree/locale/<lang>/LC_MESSAGES/django.po
|
||||
|
||||
This script presents the translator with a list of strings which have not yet been translated,
|
||||
allowing for a simpler and quicker translation process.
|
||||
|
||||
If a string translation needs to be updated, this will still need to be done manually,
|
||||
by editing the appropriate .po file.
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def manually_translate_file(filename, save=False):
|
||||
"""
|
||||
Manually translate a .po file.
|
||||
Present any missing translation strings to the translator,
|
||||
and write their responses back to the file.
|
||||
"""
|
||||
|
||||
print("Add manual translations to '{f}'".format(f=filename))
|
||||
print("For each missing translation:")
|
||||
print("a) Directly enter a new tranlation in the target language")
|
||||
print("b) Leave empty to skip")
|
||||
print("c) Press Ctrl+C to exit")
|
||||
|
||||
print("-------------------------")
|
||||
input("Press <ENTER> to start")
|
||||
print("")
|
||||
|
||||
with open(filename, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
out = []
|
||||
|
||||
# Context data
|
||||
source_line = ''
|
||||
msgid = ''
|
||||
|
||||
for num, line in enumerate(lines):
|
||||
# Keep track of context data BEFORE an empty msgstr object
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith("#: "):
|
||||
source_line = line.replace("#: ", "")
|
||||
|
||||
elif line.startswith("msgid "):
|
||||
msgid = line.replace("msgid ", "")
|
||||
|
||||
if line.strip() == 'msgstr ""':
|
||||
# We have found an empty translation!
|
||||
|
||||
if msgid and len(msgid) > 0 and not msgid == '""':
|
||||
print("Source:", source_line)
|
||||
print("Enter translation for {t}".format(t=msgid))
|
||||
|
||||
try:
|
||||
translation = str(input(">"))
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
if translation and len(translation) > 0:
|
||||
# Update the line with the new translation
|
||||
line = 'msgstr "{msg}"'.format(msg=translation)
|
||||
|
||||
out.append(line + "\r\n")
|
||||
|
||||
if save:
|
||||
with open(filename, 'w') as output_file:
|
||||
output_file.writelines(out)
|
||||
|
||||
print("Translation done: written to", filename)
|
||||
print("Run 'invoke translate' to rebuild translation data")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
MY_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
LOCALE_DIR = os.path.join(MY_DIR, '..', 'locale')
|
||||
|
||||
if not os.path.exists(LOCALE_DIR):
|
||||
print("Error: {d} does not exist!".format(d=LOCALE_DIR))
|
||||
sys.exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description="InvenTree Translation Helper")
|
||||
|
||||
parser.add_argument('language', help='Language code', action='store')
|
||||
|
||||
parser.add_argument('--fake', help="Do not save updated translations", action='store_true')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
language = args.language
|
||||
|
||||
LANGUAGE_DIR = os.path.abspath(os.path.join(LOCALE_DIR, language))
|
||||
|
||||
# Check that a locale directory exists for the given language!
|
||||
if not os.path.exists(LANGUAGE_DIR):
|
||||
print("Error: Locale directory for language '{l}' does not exist".format(l=language))
|
||||
sys.exit(1)
|
||||
|
||||
# Check that a .po file exists for the given language!
|
||||
PO_FILE = os.path.join(LANGUAGE_DIR, 'LC_MESSAGES', 'django.po')
|
||||
|
||||
if not os.path.exists(PO_FILE):
|
||||
print("Error: File '{f}' does not exist".format(f=PO_FILE))
|
||||
sys.exit(1)
|
||||
|
||||
# Ok, now we run the user through the translation file
|
||||
manually_translate_file(PO_FILE, save=args.fake is not True)
|
||||
+83
-3
@@ -402,11 +402,51 @@ class StockFilter(rest_filters.FilterSet):
|
||||
serialized = rest_filters.BooleanFilter(label='Has serial number', method='filter_serialized')
|
||||
|
||||
def filter_serialized(self, queryset, name, value):
|
||||
"""
|
||||
Filter by whether the StockItem has a serial number (or not)
|
||||
"""
|
||||
|
||||
q = Q(serial=None) | Q(serial='')
|
||||
|
||||
if str2bool(value):
|
||||
queryset = queryset.exclude(serial=None)
|
||||
queryset = queryset.exclude(q)
|
||||
else:
|
||||
queryset = queryset.filter(serial=None)
|
||||
queryset = queryset.filter(q)
|
||||
|
||||
return queryset
|
||||
|
||||
has_batch = rest_filters.BooleanFilter(label='Has batch code', method='filter_has_batch')
|
||||
|
||||
def filter_has_batch(self, queryset, name, value):
|
||||
"""
|
||||
Filter by whether the StockItem has a batch code (or not)
|
||||
"""
|
||||
|
||||
q = Q(batch=None) | Q(batch='')
|
||||
|
||||
if str2bool(value):
|
||||
queryset = queryset.exclude(q)
|
||||
else:
|
||||
queryset = queryset.filter(q)
|
||||
|
||||
return queryset
|
||||
|
||||
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
|
||||
|
||||
def filter_tracked(self, queryset, name, value):
|
||||
"""
|
||||
Filter by whether this stock item is *tracked*, meaning either:
|
||||
- It has a serial number
|
||||
- It has a batch code
|
||||
"""
|
||||
|
||||
q_batch = Q(batch=None) | Q(batch='')
|
||||
q_serial = Q(serial=None) | Q(serial='')
|
||||
|
||||
if str2bool(value):
|
||||
queryset = queryset.exclude(q_batch & q_serial)
|
||||
else:
|
||||
queryset = queryset.filter(q_batch & q_serial)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1105,7 +1145,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'stock_item',
|
||||
'test',
|
||||
'user',
|
||||
'result',
|
||||
@@ -1114,6 +1153,38 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
|
||||
ordering = 'date'
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by stock item
|
||||
item = params.get('stock_item', None)
|
||||
|
||||
if item is not None:
|
||||
try:
|
||||
item = StockItem.objects.get(pk=item)
|
||||
|
||||
items = [item]
|
||||
|
||||
# Do we wish to also include test results for 'installed' items?
|
||||
include_installed = str2bool(params.get('include_installed', False))
|
||||
|
||||
if include_installed:
|
||||
# Include items which are installed "underneath" this item
|
||||
# Note that this function is recursive!
|
||||
installed_items = item.get_installed_items(cascade=True)
|
||||
|
||||
items += [it for it in installed_items]
|
||||
|
||||
queryset = queryset.filter(stock_item__in=items)
|
||||
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
try:
|
||||
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
||||
@@ -1189,6 +1260,15 @@ class StockTrackingList(generics.ListAPIView):
|
||||
if not deltas:
|
||||
deltas = {}
|
||||
|
||||
# Add part detail
|
||||
if 'part' in deltas:
|
||||
try:
|
||||
part = Part.objects.get(pk=deltas['part'])
|
||||
serializer = PartBriefSerializer(part)
|
||||
deltas['part_detail'] = serializer.data
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add location detail
|
||||
if 'location' in deltas:
|
||||
try:
|
||||
|
||||
@@ -251,3 +251,104 @@
|
||||
rght: 0
|
||||
expiry_date: "1990-10-10"
|
||||
status: 70
|
||||
|
||||
# Multiple stock items for "Bob" (PK 100)
|
||||
- model: stock.stockitem
|
||||
pk: 1000
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 10
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1001
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 11
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1002
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 12
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1003
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 13
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1004
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 14
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1005
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 15
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1006
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 16
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1007
|
||||
fields:
|
||||
part: 100
|
||||
location: 7
|
||||
quantity: 17
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1008
|
||||
fields:
|
||||
part: 100
|
||||
location: 7
|
||||
quantity: 18
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.12 on 2022-04-26 10:19
|
||||
|
||||
from django.db import migrations, models
|
||||
import stock.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0073_alter_stockitem_belongs_to'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='batch',
|
||||
field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,8 @@ from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.exceptions import ValidationError, FieldError
|
||||
from django.urls import reverse
|
||||
@@ -213,6 +215,32 @@ class StockItemManager(TreeManager):
|
||||
)
|
||||
|
||||
|
||||
def generate_batch_code():
|
||||
"""
|
||||
Generate a default 'batch code' for a new StockItem.
|
||||
|
||||
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
|
||||
which can be passed through a simple template.
|
||||
"""
|
||||
|
||||
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Pass context data through to the template randering.
|
||||
# The folowing context variables are availble for custom batch code generation
|
||||
context = {
|
||||
'date': now,
|
||||
'year': now.year,
|
||||
'month': now.month,
|
||||
'day': now.day,
|
||||
'hour': now.minute,
|
||||
'minute': now.minute,
|
||||
}
|
||||
|
||||
return Template(batch_template).render(context)
|
||||
|
||||
|
||||
class StockItem(MPTTModel):
|
||||
"""
|
||||
A StockItem object represents a quantity of physical instances of a part.
|
||||
@@ -453,6 +481,14 @@ class StockItem(MPTTModel):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Strip serial number field
|
||||
if type(self.serial) is str:
|
||||
self.serial = self.serial.strip()
|
||||
|
||||
# Strip batch code field
|
||||
if type(self.batch) is str:
|
||||
self.batch = self.batch.strip()
|
||||
|
||||
try:
|
||||
if self.part.trackable:
|
||||
# Trackable parts must have integer values for quantity field!
|
||||
@@ -636,7 +672,8 @@ class StockItem(MPTTModel):
|
||||
batch = models.CharField(
|
||||
verbose_name=_('Batch Code'),
|
||||
max_length=100, blank=True, null=True,
|
||||
help_text=_('Batch code for this stock item')
|
||||
help_text=_('Batch code for this stock item'),
|
||||
default=generate_batch_code,
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(
|
||||
@@ -718,6 +755,33 @@ class StockItem(MPTTModel):
|
||||
help_text=_('Select Owner'),
|
||||
related_name='stock_items')
|
||||
|
||||
@transaction.atomic
|
||||
def convert_to_variant(self, variant, user, notes=None):
|
||||
"""
|
||||
Convert this StockItem instance to a "variant",
|
||||
i.e. change the "part" reference field
|
||||
"""
|
||||
|
||||
if not variant:
|
||||
# Ignore null values
|
||||
return
|
||||
|
||||
if variant == self.part:
|
||||
# Variant is the same as the current part
|
||||
return
|
||||
|
||||
self.part = variant
|
||||
self.save()
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.CONVERTED_TO_VARIANT,
|
||||
user,
|
||||
deltas={
|
||||
'part': variant.pk,
|
||||
},
|
||||
notes=_('Converted to part') + ': ' + variant.full_name,
|
||||
)
|
||||
|
||||
def get_item_owner(self):
|
||||
"""
|
||||
Return the closest "owner" for this StockItem.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "stock/stock_sidebar.html" %}
|
||||
@@ -27,11 +26,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='table-toolbar'>
|
||||
<div id='tracking-table-toolbar'>
|
||||
<div class='btn-group'>
|
||||
{% include "filter_list.html" with id="stocktracking" %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
|
||||
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#tracking-table-toolbar'>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,24 +133,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Stock Item Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Stock Item Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if item.notes %}
|
||||
{{ item.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='stock-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,18 +227,21 @@
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-stock-detail" item.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
onPanelLoad('notes', function() {
|
||||
setupNotesField(
|
||||
'stock-notes',
|
||||
'{% url "api-stock-detail" item.pk %}',
|
||||
{
|
||||
{% if roles.stock.change and user_owns_item %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
@@ -348,7 +343,6 @@
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
loadStockTrackingTable($("#track-table"), {
|
||||
params: {
|
||||
ordering: '-date',
|
||||
|
||||
@@ -49,15 +49,20 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Document / label menu -->
|
||||
{% if test_report_enabled or labels_enabled %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
{% if test_report_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stock adjustment menu -->
|
||||
{% if user_owns_item %}
|
||||
{% if roles.stock.change and not item.is_building %}
|
||||
@@ -500,7 +505,12 @@ $("#barcode-unlink").click(function() {
|
||||
});
|
||||
|
||||
$("#barcode-scan-into-location").click(function() {
|
||||
scanItemsIntoLocation([{{ item.id }}]);
|
||||
|
||||
inventreeGet('{% url "api-stock-detail" item.pk %}', {}, {
|
||||
success: function(item) {
|
||||
scanItemsIntoLocation([item]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function itemAdjust(action) {
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -181,6 +183,7 @@
|
||||
<div id='sublocation-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
<!-- Printing actions menu -->
|
||||
{% if labels_enabled %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='location-print-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle="dropdown" title='{% trans "Printing Actions" %}'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
@@ -189,6 +192,7 @@
|
||||
<li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "filter_list.html" with id="location" %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +226,15 @@
|
||||
]
|
||||
);
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
|
||||
var locs = [{{ location.pk }}];
|
||||
|
||||
printStockLocationLabels(locs);
|
||||
|
||||
});
|
||||
|
||||
$('#multi-location-print-label').click(function() {
|
||||
|
||||
var selections = $('#sublocation-table').bootstrapTable('getSelections');
|
||||
@@ -234,6 +247,7 @@
|
||||
|
||||
printStockLocationLabels(locations);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if location %}
|
||||
$("#barcode-check-in").click(function() {
|
||||
@@ -298,14 +312,6 @@
|
||||
adjustLocationStock('move');
|
||||
});
|
||||
|
||||
$('#print-label').click(function() {
|
||||
|
||||
var locs = [{{ location.pk }}];
|
||||
|
||||
printStockLocationLabels(locs);
|
||||
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
$('#show-qr-code').click(function() {
|
||||
|
||||
+56
-15
@@ -104,7 +104,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
response = self.get_stock()
|
||||
|
||||
self.assertEqual(len(response), 20)
|
||||
self.assertEqual(len(response), 29)
|
||||
|
||||
def test_filter_by_part(self):
|
||||
"""
|
||||
@@ -113,7 +113,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
response = self.get_stock(part=25)
|
||||
|
||||
self.assertEqual(len(response), 8)
|
||||
self.assertEqual(len(response), 17)
|
||||
|
||||
response = self.get_stock(part=10004)
|
||||
|
||||
@@ -136,13 +136,13 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(location=1, cascade=0)
|
||||
self.assertEqual(len(response), 0)
|
||||
self.assertEqual(len(response), 7)
|
||||
|
||||
response = self.get_stock(location=1, cascade=1)
|
||||
self.assertEqual(len(response), 2)
|
||||
self.assertEqual(len(response), 9)
|
||||
|
||||
response = self.get_stock(location=7)
|
||||
self.assertEqual(len(response), 16)
|
||||
self.assertEqual(len(response), 18)
|
||||
|
||||
def test_filter_by_depleted(self):
|
||||
"""
|
||||
@@ -153,7 +153,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(depleted=0)
|
||||
self.assertEqual(len(response), 19)
|
||||
self.assertEqual(len(response), 28)
|
||||
|
||||
def test_filter_by_in_stock(self):
|
||||
"""
|
||||
@@ -161,7 +161,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
"""
|
||||
|
||||
response = self.get_stock(in_stock=1)
|
||||
self.assertEqual(len(response), 17)
|
||||
self.assertEqual(len(response), 26)
|
||||
|
||||
response = self.get_stock(in_stock=0)
|
||||
self.assertEqual(len(response), 3)
|
||||
@@ -172,7 +172,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
"""
|
||||
|
||||
codes = {
|
||||
StockStatus.OK: 18,
|
||||
StockStatus.OK: 27,
|
||||
StockStatus.DESTROYED: 1,
|
||||
StockStatus.LOST: 1,
|
||||
StockStatus.DAMAGED: 0,
|
||||
@@ -205,11 +205,51 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertIsNotNone(item['serial'])
|
||||
|
||||
response = self.get_stock(serialized=0)
|
||||
self.assertEqual(len(response), 8)
|
||||
self.assertEqual(len(response), 17)
|
||||
|
||||
for item in response:
|
||||
self.assertIsNone(item['serial'])
|
||||
|
||||
def test_filter_by_has_batch(self):
|
||||
"""
|
||||
Test the 'has_batch' filter, which tests if the stock item has been assigned a batch code
|
||||
"""
|
||||
|
||||
with_batch = self.get_stock(has_batch=1)
|
||||
without_batch = self.get_stock(has_batch=0)
|
||||
|
||||
n_stock_items = StockItem.objects.all().count()
|
||||
|
||||
# Total sum should equal the total count of stock items
|
||||
self.assertEqual(n_stock_items, len(with_batch) + len(without_batch))
|
||||
|
||||
for item in with_batch:
|
||||
self.assertFalse(item['batch'] in [None, ''])
|
||||
|
||||
for item in without_batch:
|
||||
self.assertTrue(item['batch'] in [None, ''])
|
||||
|
||||
def test_filter_by_tracked(self):
|
||||
"""
|
||||
Test the 'tracked' filter.
|
||||
This checks if the stock item has either a batch code *or* a serial number
|
||||
"""
|
||||
|
||||
tracked = self.get_stock(tracked=True)
|
||||
untracked = self.get_stock(tracked=False)
|
||||
|
||||
n_stock_items = StockItem.objects.all().count()
|
||||
|
||||
self.assertEqual(n_stock_items, len(tracked) + len(untracked))
|
||||
|
||||
blank = [None, '']
|
||||
|
||||
for item in tracked:
|
||||
self.assertTrue(item['batch'] not in blank or item['serial'] not in blank)
|
||||
|
||||
for item in untracked:
|
||||
self.assertTrue(item['batch'] in blank and item['serial'] in blank)
|
||||
|
||||
def test_filter_by_expired(self):
|
||||
"""
|
||||
Filter StockItem by expiry status
|
||||
@@ -217,7 +257,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
# First, we can assume that the 'stock expiry' feature is disabled
|
||||
response = self.get_stock(expired=1)
|
||||
self.assertEqual(len(response), 20)
|
||||
self.assertEqual(len(response), 29)
|
||||
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
@@ -232,7 +272,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertTrue(item['expired'])
|
||||
|
||||
response = self.get_stock(expired=0)
|
||||
self.assertEqual(len(response), 19)
|
||||
self.assertEqual(len(response), 28)
|
||||
|
||||
for item in response:
|
||||
self.assertFalse(item['expired'])
|
||||
@@ -249,7 +289,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertEqual(len(response), 4)
|
||||
|
||||
response = self.get_stock(expired=0)
|
||||
self.assertEqual(len(response), 16)
|
||||
self.assertEqual(len(response), 25)
|
||||
|
||||
def test_paginate(self):
|
||||
"""
|
||||
@@ -290,7 +330,8 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
dataset = self.export_data({})
|
||||
|
||||
self.assertEqual(len(dataset), 20)
|
||||
# Check that *all* stock item objects have been exported
|
||||
self.assertEqual(len(dataset), StockItem.objects.count())
|
||||
|
||||
# Expected headers
|
||||
headers = [
|
||||
@@ -308,11 +349,11 @@ class StockItemListTest(StockAPITestCase):
|
||||
# Now, add a filter to the results
|
||||
dataset = self.export_data({'location': 1})
|
||||
|
||||
self.assertEqual(len(dataset), 2)
|
||||
self.assertEqual(len(dataset), 9)
|
||||
|
||||
dataset = self.export_data({'part': 25})
|
||||
|
||||
self.assertEqual(len(dataset), 8)
|
||||
self.assertEqual(len(dataset), 17)
|
||||
|
||||
|
||||
class StockItemTest(StockAPITestCase):
|
||||
|
||||
@@ -167,8 +167,8 @@ class StockTest(TestCase):
|
||||
self.assertFalse(self.drawer2.has_items())
|
||||
|
||||
# Drawer 3 should have three stock items
|
||||
self.assertEqual(self.drawer3.stock_items.count(), 16)
|
||||
self.assertEqual(self.drawer3.item_count, 16)
|
||||
self.assertEqual(self.drawer3.stock_items.count(), 18)
|
||||
self.assertEqual(self.drawer3.item_count, 18)
|
||||
|
||||
def test_stock_count(self):
|
||||
part = Part.objects.get(pk=1)
|
||||
|
||||
@@ -644,6 +644,16 @@ class StockItemConvert(AjaxUpdateView):
|
||||
|
||||
return form
|
||||
|
||||
def save(self, obj, form):
|
||||
|
||||
stock_item = self.get_object()
|
||||
|
||||
variant = form.cleaned_data.get('part', None)
|
||||
|
||||
stock_item.convert_to_variant(variant, user=self.request.user)
|
||||
|
||||
return stock_item
|
||||
|
||||
|
||||
class StockLocationCreate(AjaxCreateView):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Permission Denied" %}
|
||||
{% inventree_title %} | {% trans "Permission Denied" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Page Not Found" %}
|
||||
{% inventree_title %} | {% trans "Page Not Found" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Internal Server Error" %}
|
||||
{% inventree_title %} | {% trans "Internal Server Error" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -11,7 +12,7 @@ InvenTree | {% trans "Internal Server Error" %}
|
||||
<h3>{% trans "Internal Server Error" %}</h3>
|
||||
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans "The InvenTree server raised an internal error" %}<br>
|
||||
{% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}<br>
|
||||
{% trans "Refer to the error log in the admin interface for further details" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class='container-fluid'>
|
||||
|
||||
<div class='clearfix content-heading login-header d-flex flex-wrap'>
|
||||
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
|
||||
<img class="pull-left" src="{% inventree_logo %}" width="60" height="60"/>
|
||||
{% include "spacer.html" %}
|
||||
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_RESTRICT_ABOUT" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{% block content %}
|
||||
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %}
|
||||
{% trans "Changing the settings below require you to immediatly restart the server. Do not change this while under active usage." %}
|
||||
</div>
|
||||
|
||||
<div class='table-responsive'>
|
||||
@@ -24,6 +24,7 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PLUGIN_ON_STARTUP" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -76,6 +77,12 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if plugin.is_sample %}
|
||||
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
|
||||
<span class='badge bg-info rounded-pill'>{% trans "code sample" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if plugin.website %}
|
||||
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
|
||||
{% endif %}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{% if plugin.is_package %}
|
||||
{% trans "This plugin was installed as a package" %}
|
||||
{% else %}
|
||||
{% trans "This plugin was found in a local InvenTree path" %}
|
||||
{% trans "This plugin was found in a local server path" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -89,7 +89,7 @@ $('table').find('.boolean-setting').change(function() {
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
onSuccess: function(data) {
|
||||
success: function(data) {
|
||||
},
|
||||
error: function(xhr) {
|
||||
showApiError(xhr, url);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
{% inventree_demo_mode as demo %}
|
||||
{% if not demo %}
|
||||
{% inventree_customize 'hide_password_reset' as hide_password_reset %}
|
||||
{% if not hide_password_reset %}
|
||||
<div class='btn btn-outline-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
|
||||
<span class='fas fa-key'></span> {% trans "Set Password" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user