Compare commits
67 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
57e9497da1 | |
|
|
a7c0882c02 | |
|
|
5d55555394 | |
|
|
d2a313bda9 | |
|
|
e366cd1865 | |
|
|
86542bc561 | |
|
|
a7487ff842 | |
|
|
5725a9e271 | |
|
|
fe9a56a5c1 | |
|
|
39e682cd45 | |
|
|
a36ab0c004 | |
|
|
0b45d6f236 | |
|
|
978e08f3a3 | |
|
|
85b8157611 | |
|
|
aaabce9873 | |
|
|
f5a36ce44e | |
|
|
6563b4c413 | |
|
|
abed9fb284 | |
|
|
09872eec8e | |
|
|
099b837a4e | |
|
|
cf977ad29a | |
|
|
72464c50cc | |
|
|
942bc5350d | |
|
|
7876676114 | |
|
|
ea039645c3 | |
|
|
b5c7cf0779 | |
|
|
89d8e47bab | |
|
|
b8e726d8a4 | |
|
|
3b238fdbba | |
|
|
df8c2692a0 | |
|
|
7391f33a97 | |
|
|
b1158f7083 | |
|
|
4969628150 | |
|
|
57eada1da1 | |
|
|
f526dcdeec | |
|
|
aacf35ed47 | |
|
|
ca986cba01 | |
|
|
699fb83dd4 | |
|
|
dd6e225cda | |
|
|
1f3a49b1ae | |
|
|
385e7cb478 | |
|
|
73768bfee1 | |
|
|
946fe2df29 | |
|
|
afa7ed873f | |
|
|
46da332afe | |
|
|
072b7b3146 | |
|
|
1d51b2a058 | |
|
|
08f9bebdf0 | |
|
|
6d6629f11c | |
|
|
db88fbda11 | |
|
|
49c9b5b1aa | |
|
|
e1a0e79ead | |
|
|
ab22f2a04d | |
|
|
8a58bf5ffa | |
|
|
6730098bac | |
|
|
93b44ad8e6 | |
|
|
9b5e828b87 | |
|
|
cf5d637678 | |
|
|
feb2acf668 | |
|
|
0017570dd3 | |
|
|
4c41a50bb1 | |
|
|
eab3fdcf2c | |
|
|
c59eee7359 | |
|
|
4a5ebf8f01 | |
|
|
698798fee7 | |
|
|
2660889879 | |
|
|
01aaf95a0e |
|
|
@ -60,7 +60,7 @@ jobs:
|
||||||
docker-compose run inventree-dev-server invoke update
|
docker-compose run inventree-dev-server invoke update
|
||||||
docker-compose run inventree-dev-server invoke setup-dev
|
docker-compose run inventree-dev-server invoke setup-dev
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
docker-compose run inventree-dev-server pip install --upgrade setuptools
|
docker-compose run inventree-dev-server pip install setuptools==68.1.2
|
||||||
docker-compose run inventree-dev-server invoke wait
|
docker-compose run inventree-dev-server invoke wait
|
||||||
- name: Check Data Directory
|
- name: Check Data Directory
|
||||||
# The following file structure should have been created by the docker image
|
# The following file structure should have been created by the docker image
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
"""Admin classes"""
|
"""Admin classes"""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.http.request import HttpRequest
|
||||||
|
|
||||||
|
from djmoney.contrib.exchange.admin import RateAdmin
|
||||||
|
from djmoney.contrib.exchange.models import Rate
|
||||||
from import_export.resources import ModelResource
|
from import_export.resources import ModelResource
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,3 +36,27 @@ class InvenTreeResource(ModelResource):
|
||||||
row[idx] = val
|
row[idx] = val
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def get_fields(self, **kwargs):
|
||||||
|
"""Return fields, with some common exclusions"""
|
||||||
|
|
||||||
|
fields = super().get_fields(**kwargs)
|
||||||
|
|
||||||
|
fields_to_exclude = [
|
||||||
|
'metadata',
|
||||||
|
'lft', 'rght', 'tree_id', 'level',
|
||||||
|
]
|
||||||
|
|
||||||
|
return [f for f in fields if f.column_name not in fields_to_exclude]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRateAdmin(RateAdmin):
|
||||||
|
"""Admin interface for the Rate class"""
|
||||||
|
|
||||||
|
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||||
|
"""Disable the 'add' permission for Rate objects"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.unregister(Rate)
|
||||||
|
admin.site.register(Rate, CustomRateAdmin)
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,39 @@ class NotFoundView(AjaxView):
|
||||||
|
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def not_found(self, request):
|
||||||
"""Process an `not found` event on the API."""
|
"""Return a 404 error"""
|
||||||
data = {
|
return JsonResponse(
|
||||||
'details': _('API endpoint not found'),
|
{
|
||||||
'url': request.build_absolute_uri(),
|
'detail': _('API endpoint not found'),
|
||||||
}
|
'url': request.build_absolute_uri(),
|
||||||
|
},
|
||||||
|
status=404
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse(data, status=404)
|
def options(self, request, *args, **kwargs):
|
||||||
|
"""Return 404"""
|
||||||
|
return self.not_found(request)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Return 404"""
|
||||||
|
return self.not_found(request)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Return 404"""
|
||||||
|
return self.not_found(request)
|
||||||
|
|
||||||
|
def patch(self, request, *args, **kwargs):
|
||||||
|
"""Return 404"""
|
||||||
|
return self.not_found(request)
|
||||||
|
|
||||||
|
def put(self, request, *args, **kwargs):
|
||||||
|
"""Return 404"""
|
||||||
|
return self.not_found(request)
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
"""Return 404"""
|
||||||
|
return self.not_found(request)
|
||||||
|
|
||||||
|
|
||||||
class BulkDeleteMixin:
|
class BulkDeleteMixin:
|
||||||
|
|
|
||||||
|
|
@ -195,8 +195,8 @@ class InvenTreeConfig(AppConfig):
|
||||||
else:
|
else:
|
||||||
new_user = user.objects.create_superuser(add_user, add_email, add_password)
|
new_user = user.objects.create_superuser(add_user, add_email, add_password)
|
||||||
logger.info(f'User {str(new_user)} was created!')
|
logger.info(f'User {str(new_user)} was created!')
|
||||||
except IntegrityError as _e:
|
except IntegrityError:
|
||||||
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
|
logger.warning(f'The user "{add_user}" could not be created')
|
||||||
|
|
||||||
# do not try again
|
# do not try again
|
||||||
settings.USER_ADDED = True
|
settings.USER_ADDED = True
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ def convert_physical_value(value: str, unit: str = None):
|
||||||
# At this point we *should* have a valid pint value
|
# At this point we *should* have a valid pint value
|
||||||
# To double check, look at the maginitude
|
# To double check, look at the maginitude
|
||||||
float(val.magnitude)
|
float(val.magnitude)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError, AttributeError):
|
||||||
error = _('Provided value is not a valid number')
|
error = _('Provided value is not a valid number')
|
||||||
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
|
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
|
||||||
error = _('Provided value has an invalid unit')
|
error = _('Provided value has an invalid unit')
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ def is_email_configured():
|
||||||
NOTE: This does not check if the configuration is valid!
|
NOTE: This does not check if the configuration is valid!
|
||||||
"""
|
"""
|
||||||
configured = True
|
configured = True
|
||||||
|
testing = settings.TESTING
|
||||||
|
|
||||||
if InvenTree.ready.isInTestMode():
|
if InvenTree.ready.isInTestMode():
|
||||||
return False
|
return False
|
||||||
|
|
@ -28,17 +29,24 @@ def is_email_configured():
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING: # pragma: no cover
|
if not testing: # pragma: no cover
|
||||||
logger.debug("EMAIL_HOST is not configured")
|
logger.debug("EMAIL_HOST is not configured")
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.EMAIL_HOST_USER and not settings.TESTING: # pragma: no cover
|
if not settings.EMAIL_HOST_USER and not testing: # pragma: no cover
|
||||||
logger.debug("EMAIL_HOST_USER is not configured")
|
logger.debug("EMAIL_HOST_USER is not configured")
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.EMAIL_HOST_PASSWORD and not settings.TESTING: # pragma: no cover
|
if not settings.EMAIL_HOST_PASSWORD and testing: # pragma: no cover
|
||||||
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||||
|
|
||||||
|
# Email sender must be configured
|
||||||
|
if not settings.DEFAULT_FROM_EMAIL:
|
||||||
|
configured = False
|
||||||
|
|
||||||
|
if not testing: # pragma: no cover
|
||||||
|
logger.warning("DEFAULT_FROM_EMAIL is not configured")
|
||||||
|
|
||||||
return configured
|
return configured
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
"""Exchangerate backend to use `exchangerate.host` to get rates."""
|
"""Exchangerate backend to use `frankfurter.app` to get rates."""
|
||||||
|
|
||||||
import ssl
|
from decimal import Decimal
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
from urllib.request import urlopen
|
|
||||||
|
|
||||||
from django.db.utils import OperationalError
|
from django.db.utils import OperationalError
|
||||||
|
|
||||||
import certifi
|
import requests
|
||||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||||
|
|
||||||
from common.settings import currency_code_default, currency_codes
|
from common.settings import currency_code_default, currency_codes
|
||||||
|
|
@ -15,19 +14,19 @@ from common.settings import currency_code_default, currency_codes
|
||||||
class InvenTreeExchange(SimpleExchangeBackend):
|
class InvenTreeExchange(SimpleExchangeBackend):
|
||||||
"""Backend for automatically updating currency exchange rates.
|
"""Backend for automatically updating currency exchange rates.
|
||||||
|
|
||||||
Uses the `exchangerate.host` service API
|
Uses the `frankfurter.app` service API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "InvenTreeExchange"
|
name = "InvenTreeExchange"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Set API url."""
|
"""Set API url."""
|
||||||
self.url = "https://api.exchangerate.host/latest"
|
self.url = "https://api.frankfurter.app/latest"
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def get_params(self):
|
def get_params(self):
|
||||||
"""Placeholder to set API key. Currently not required by `exchangerate.host`."""
|
"""Placeholder to set API key. Currently not required by `frankfurter.app`."""
|
||||||
# No API key is required
|
# No API key is required
|
||||||
return {
|
return {
|
||||||
}
|
}
|
||||||
|
|
@ -40,14 +39,23 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||||
url = self.get_url(**kwargs)
|
url = self.get_url(**kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = ssl.create_default_context(cafile=certifi.where())
|
response = requests.get(url=url, timeout=5)
|
||||||
response = urlopen(url, timeout=5, context=context)
|
return response.content
|
||||||
return response.read()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Something has gone wrong, but we can just try again next time
|
# Something has gone wrong, but we can just try again next time
|
||||||
# Raise a TypeError so the outer function can handle this
|
# Raise a TypeError so the outer function can handle this
|
||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
|
def get_rates(self, **params):
|
||||||
|
"""Intersect the requested currency codes with the available codes."""
|
||||||
|
rates = super().get_rates(**params)
|
||||||
|
|
||||||
|
# Add the base currency to the rates
|
||||||
|
base_currency = params.get('base', currency_code_default())
|
||||||
|
rates[base_currency] = Decimal("1.0")
|
||||||
|
|
||||||
|
return rates
|
||||||
|
|
||||||
def update_rates(self, base_currency=None):
|
def update_rates(self, base_currency=None):
|
||||||
"""Set the requested currency codes and get rates."""
|
"""Set the requested currency codes and get rates."""
|
||||||
# Set default - see B008
|
# Set default - see B008
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.account.forms import SignupForm, set_form_field_order
|
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
|
||||||
from allauth.exceptions import ImmediateHttpResponse
|
from allauth.exceptions import ImmediateHttpResponse
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
from allauth_2fa.adapter import OTPAdapter
|
from allauth_2fa.adapter import OTPAdapter
|
||||||
|
|
@ -161,11 +161,30 @@ class SetPasswordForm(HelperForm):
|
||||||
old_password = forms.CharField(
|
old_password = forms.CharField(
|
||||||
label=_("Old password"),
|
label=_("Old password"),
|
||||||
strip=False,
|
strip=False,
|
||||||
|
required=False,
|
||||||
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
|
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# override allauth
|
# override allauth
|
||||||
|
class CustomLoginForm(LoginForm):
|
||||||
|
"""Custom login form to override default allauth behaviour"""
|
||||||
|
|
||||||
|
def login(self, request, redirect_url=None):
|
||||||
|
"""Perform login action.
|
||||||
|
|
||||||
|
First check that:
|
||||||
|
- A valid user has been supplied
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.user:
|
||||||
|
# No user supplied - redirect to the login page
|
||||||
|
return HttpResponseRedirect(reverse('account_login'))
|
||||||
|
|
||||||
|
# Now perform default login action
|
||||||
|
return super().login(request, redirect_url)
|
||||||
|
|
||||||
|
|
||||||
class CustomSignupForm(SignupForm):
|
class CustomSignupForm(SignupForm):
|
||||||
"""Override to use dynamic settings."""
|
"""Override to use dynamic settings."""
|
||||||
|
|
||||||
|
|
@ -292,6 +311,15 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||||
|
"""Construct the email confirmation url"""
|
||||||
|
|
||||||
|
from InvenTree.helpers_model import construct_absolute_url
|
||||||
|
|
||||||
|
url = super().get_email_confirmation_url(request, emailconfirmation)
|
||||||
|
url = construct_absolute_url(url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
|
class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter):
|
||||||
"""Override of adapter to use dynamic settings."""
|
"""Override of adapter to use dynamic settings."""
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
|
|
@ -50,9 +51,7 @@ def construct_absolute_url(*arg, **kwargs):
|
||||||
# Otherwise, try to use the InvenTree setting
|
# Otherwise, try to use the InvenTree setting
|
||||||
try:
|
try:
|
||||||
site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
|
site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
|
||||||
except ProgrammingError:
|
except (ProgrammingError, OperationalError):
|
||||||
pass
|
|
||||||
except OperationalError:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not site_url:
|
if not site_url:
|
||||||
|
|
@ -66,14 +65,7 @@ def construct_absolute_url(*arg, **kwargs):
|
||||||
# No site URL available, return the relative URL
|
# No site URL available, return the relative URL
|
||||||
return relative_url
|
return relative_url
|
||||||
|
|
||||||
# Strip trailing slash from base url
|
return urljoin(site_url, relative_url)
|
||||||
if site_url.endswith('/'):
|
|
||||||
site_url = site_url[:-1]
|
|
||||||
|
|
||||||
if relative_url.startswith('/'):
|
|
||||||
relative_url = relative_url[1:]
|
|
||||||
|
|
||||||
return f"{site_url}/{relative_url}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_url(**kwargs):
|
def get_base_url(**kwargs):
|
||||||
|
|
|
||||||
|
|
@ -601,6 +601,8 @@ DATABASES = {
|
||||||
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
|
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
|
||||||
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
|
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = "/index/"
|
||||||
|
|
||||||
# sentry.io integration for error reporting
|
# sentry.io integration for error reporting
|
||||||
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)
|
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)
|
||||||
|
|
||||||
|
|
@ -757,14 +759,14 @@ LANGUAGES = [
|
||||||
('no', _('Norwegian')),
|
('no', _('Norwegian')),
|
||||||
('pl', _('Polish')),
|
('pl', _('Polish')),
|
||||||
('pt', _('Portuguese')),
|
('pt', _('Portuguese')),
|
||||||
('pt-BR', _('Portuguese (Brazilian)')),
|
('pt-br', _('Portuguese (Brazilian)')),
|
||||||
('ru', _('Russian')),
|
('ru', _('Russian')),
|
||||||
('sl', _('Slovenian')),
|
('sl', _('Slovenian')),
|
||||||
('sv', _('Swedish')),
|
('sv', _('Swedish')),
|
||||||
('th', _('Thai')),
|
('th', _('Thai')),
|
||||||
('tr', _('Turkish')),
|
('tr', _('Turkish')),
|
||||||
('vi', _('Vietnamese')),
|
('vi', _('Vietnamese')),
|
||||||
('zh-hans', _('Chinese')),
|
('zh-hans', _('Chinese (Simplified)')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Testing interface translations
|
# Testing interface translations
|
||||||
|
|
@ -822,6 +824,10 @@ EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False)
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
|
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_SENDER', 'email.sender', '')
|
||||||
|
|
||||||
|
# If "from" email not specified, default to the username
|
||||||
|
if not DEFAULT_FROM_EMAIL:
|
||||||
|
DEFAULT_FROM_EMAIL = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '')
|
||||||
|
|
||||||
EMAIL_USE_LOCALTIME = False
|
EMAIL_USE_LOCALTIME = False
|
||||||
EMAIL_TIMEOUT = 60
|
EMAIL_TIMEOUT = 60
|
||||||
|
|
||||||
|
|
@ -871,7 +877,7 @@ ACCOUNT_PREVENT_ENUMERATION = True
|
||||||
|
|
||||||
# override forms / adapters
|
# override forms / adapters
|
||||||
ACCOUNT_FORMS = {
|
ACCOUNT_FORMS = {
|
||||||
'login': 'allauth.account.forms.LoginForm',
|
'login': 'InvenTree.forms.CustomLoginForm',
|
||||||
'signup': 'InvenTree.forms.CustomSignupForm',
|
'signup': 'InvenTree.forms.CustomSignupForm',
|
||||||
'add_email': 'allauth.account.forms.AddEmailForm',
|
'add_email': 'allauth.account.forms.AddEmailForm',
|
||||||
'change_password': 'allauth.account.forms.ChangePasswordForm',
|
'change_password': 'allauth.account.forms.ChangePasswordForm',
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,23 @@ class ConversionTest(TestCase):
|
||||||
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
|
q = InvenTree.conversion.convert_physical_value(val).to_base_units()
|
||||||
self.assertEqual(q.magnitude, expected)
|
self.assertEqual(q.magnitude, expected)
|
||||||
|
|
||||||
|
def test_invalid_values(self):
|
||||||
|
"""Test conversion of invalid inputs"""
|
||||||
|
|
||||||
|
inputs = [
|
||||||
|
'-',
|
||||||
|
';;',
|
||||||
|
'-x',
|
||||||
|
'?',
|
||||||
|
'--',
|
||||||
|
'+',
|
||||||
|
'++',
|
||||||
|
]
|
||||||
|
|
||||||
|
for val in inputs:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
InvenTree.conversion.convert_physical_value(val)
|
||||||
|
|
||||||
|
|
||||||
class ValidatorTest(TestCase):
|
class ValidatorTest(TestCase):
|
||||||
"""Simple tests for custom field validators."""
|
"""Simple tests for custom field validators."""
|
||||||
|
|
@ -216,6 +233,34 @@ class FormatTest(TestCase):
|
||||||
class TestHelpers(TestCase):
|
class TestHelpers(TestCase):
|
||||||
"""Tests for InvenTree helper functions."""
|
"""Tests for InvenTree helper functions."""
|
||||||
|
|
||||||
|
def test_absolute_url(self):
|
||||||
|
"""Test helper function for generating an absolute URL"""
|
||||||
|
|
||||||
|
base = "https://demo.inventree.org:12345"
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting('INVENTREE_BASE_URL', base, change_user=None)
|
||||||
|
|
||||||
|
tests = {
|
||||||
|
"": base,
|
||||||
|
"api/": base + "/api/",
|
||||||
|
"/api/": base + "/api/",
|
||||||
|
"api": base + "/api",
|
||||||
|
"media/label/output/": base + "/media/label/output/",
|
||||||
|
"static/logo.png": base + "/static/logo.png",
|
||||||
|
"https://www.google.com": "https://www.google.com",
|
||||||
|
"https://demo.inventree.org:12345/out.html": "https://demo.inventree.org:12345/out.html",
|
||||||
|
"https://demo.inventree.org/test.html": "https://demo.inventree.org/test.html",
|
||||||
|
"http://www.cwi.nl:80/%7Eguido/Python.html": "http://www.cwi.nl:80/%7Eguido/Python.html",
|
||||||
|
"test.org": base + "/test.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
for url, expected in tests.items():
|
||||||
|
# Test with supplied base URL
|
||||||
|
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url, site_url=base), expected)
|
||||||
|
|
||||||
|
# Test without supplied base URL
|
||||||
|
self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url), expected)
|
||||||
|
|
||||||
def test_image_url(self):
|
def test_image_url(self):
|
||||||
"""Test if a filename looks like an image."""
|
"""Test if a filename looks like an image."""
|
||||||
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
|
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
|
||||||
|
|
@ -700,6 +745,7 @@ class CurrencyTests(TestCase):
|
||||||
|
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
print("Exchange rate update failed - retrying")
|
print("Exchange rate update failed - retrying")
|
||||||
|
print(f'Expected {currency_codes()}, got {[a.currency for a in rates]}')
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
self.assertTrue(update_successful)
|
self.assertTrue(update_successful)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ from django.contrib import admin
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
from django.views.generic.base import RedirectView
|
from django.views.generic.base import RedirectView
|
||||||
|
|
||||||
from dj_rest_auth.registration.views import (SocialAccountDisconnectView,
|
from dj_rest_auth.registration.views import (ConfirmEmailView,
|
||||||
|
SocialAccountDisconnectView,
|
||||||
SocialAccountListView)
|
SocialAccountListView)
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||||
|
|
||||||
|
|
@ -74,13 +75,16 @@ apipatterns = [
|
||||||
# InvenTree information endpoint
|
# InvenTree information endpoint
|
||||||
path('', InfoView.as_view(), name='api-inventree-info'),
|
path('', InfoView.as_view(), name='api-inventree-info'),
|
||||||
|
|
||||||
# Third party API endpoints
|
# Auth API endpoints
|
||||||
path('auth/', include('dj_rest_auth.urls')),
|
path('auth/', include([
|
||||||
path('auth/registration/', include('dj_rest_auth.registration.urls')),
|
re_path(r'^registration/account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'),
|
||||||
path('auth/providers/', SocialProvierListView.as_view(), name='social_providers'),
|
path('registration/', include('dj_rest_auth.registration.urls')),
|
||||||
path('auth/social/', include(social_auth_urlpatterns)),
|
path('providers/', SocialProvierListView.as_view(), name='social_providers'),
|
||||||
path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'),
|
path('social/', include(social_auth_urlpatterns)),
|
||||||
path('auth/social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
|
path('social/', SocialAccountListView.as_view(), name='social_account_list'),
|
||||||
|
path('social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
|
||||||
|
path('', include('dj_rest_auth.urls')),
|
||||||
|
])),
|
||||||
|
|
||||||
# Unknown endpoint
|
# Unknown endpoint
|
||||||
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from dulwich.repo import NotGitRepository, Repo
|
||||||
from .api_version import INVENTREE_API_VERSION
|
from .api_version import INVENTREE_API_VERSION
|
||||||
|
|
||||||
# InvenTree software version
|
# InvenTree software version
|
||||||
INVENTREE_SW_VERSION = "0.12.0"
|
INVENTREE_SW_VERSION = "0.12.9"
|
||||||
|
|
||||||
# Discover git
|
# Discover git
|
||||||
try:
|
try:
|
||||||
|
|
@ -162,8 +162,11 @@ def inventreeBranch():
|
||||||
if main_commit is None:
|
if main_commit is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
|
try:
|
||||||
return branch.removeprefix('refs/heads/')
|
branch = main_repo.refs.follow(b'HEAD')[0][1].decode()
|
||||||
|
return branch.removeprefix('refs/heads/')
|
||||||
|
except IndexError:
|
||||||
|
return None # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
def inventreeTarget():
|
def inventreeTarget():
|
||||||
|
|
|
||||||
|
|
@ -447,8 +447,7 @@ class SetPasswordView(AjaxUpdateView):
|
||||||
|
|
||||||
if valid:
|
if valid:
|
||||||
# Old password must be correct
|
# Old password must be correct
|
||||||
|
if user.has_usable_password() and not user.check_password(old_password):
|
||||||
if not user.check_password(old_password):
|
|
||||||
form.add_error('old_password', _('Wrong password provided'))
|
form.add_error('old_password', _('Wrong password provided'))
|
||||||
valid = False
|
valid = False
|
||||||
|
|
||||||
|
|
@ -640,8 +639,12 @@ class AppearanceSelectView(RedirectView):
|
||||||
user_theme = common_models.ColorTheme()
|
user_theme = common_models.ColorTheme()
|
||||||
user_theme.user = request.user
|
user_theme.user = request.user
|
||||||
|
|
||||||
user_theme.name = theme
|
if theme:
|
||||||
user_theme.save()
|
try:
|
||||||
|
user_theme.name = theme
|
||||||
|
user_theme.save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return redirect(reverse_lazy('settings'))
|
return redirect(reverse_lazy('settings'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||||
|
|
||||||
return self.build_lines.filter(bom_item__sub_part__trackable=False)
|
return self.build_lines.filter(bom_item__sub_part__trackable=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def are_untracked_parts_allocated(self):
|
||||||
|
"""Returns True if all untracked parts are allocated for this BuildOrder."""
|
||||||
|
return self.is_fully_allocated(tracked=False)
|
||||||
|
|
||||||
def has_untracked_line_items(self):
|
def has_untracked_line_items(self):
|
||||||
"""Returns True if this BuildOrder has non trackable BomItems."""
|
"""Returns True if this BuildOrder has non trackable BomItems."""
|
||||||
return self.has_untracked_line_items.count() > 0
|
return self.has_untracked_line_items.count() > 0
|
||||||
|
|
@ -714,14 +719,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||||
if items.exists() and items.count() == 1:
|
if items.exists() and items.count() == 1:
|
||||||
stock_item = items[0]
|
stock_item = items[0]
|
||||||
|
|
||||||
# Allocate the stock item
|
# Find the 'BuildLine' object which points to this BomItem
|
||||||
BuildItem.objects.create(
|
try:
|
||||||
build=self,
|
build_line = BuildLine.objects.get(
|
||||||
bom_item=bom_item,
|
build=self,
|
||||||
stock_item=stock_item,
|
bom_item=bom_item
|
||||||
quantity=1,
|
)
|
||||||
install_into=output,
|
|
||||||
)
|
# Allocate the stock items against the BuildLine
|
||||||
|
BuildItem.objects.create(
|
||||||
|
build_line=build_line,
|
||||||
|
stock_item=stock_item,
|
||||||
|
quantity=1,
|
||||||
|
install_into=output,
|
||||||
|
)
|
||||||
|
except BuildLine.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
else:
|
else:
|
||||||
"""Create a single build output of the given quantity."""
|
"""Create a single build output of the given quantity."""
|
||||||
|
|
@ -1173,7 +1186,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
|
||||||
|
|
||||||
BuildLine.objects.bulk_create(lines)
|
BuildLine.objects.bulk_create(lines)
|
||||||
|
|
||||||
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
|
if len(lines) > 0:
|
||||||
|
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def update_build_line_items(self):
|
def update_build_line_items(self):
|
||||||
|
|
|
||||||
|
|
@ -630,7 +630,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'overallocated': build.is_overallocated(),
|
'overallocated': build.is_overallocated(),
|
||||||
'allocated': build.is_fully_allocated(),
|
'allocated': build.are_untracked_parts_allocated,
|
||||||
'remaining': build.remaining,
|
'remaining': build.remaining,
|
||||||
'incomplete': build.incomplete_count,
|
'incomplete': build.incomplete_count,
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +663,7 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||||
"""Check if the 'accept_unallocated' field is required"""
|
"""Check if the 'accept_unallocated' field is required"""
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
|
|
||||||
if not build.is_fully_allocated() and not value:
|
if not build.are_untracked_parts_allocated and not value:
|
||||||
raise ValidationError(_('Required stock has not been fully allocated'))
|
raise ValidationError(_('Required stock has not been fully allocated'))
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,55 @@ import part.models as part_models
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
def update_build_order_lines(bom_item_pk: int):
|
||||||
|
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
|
||||||
|
|
||||||
|
This task is triggered when a BomItem is created or updated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info(f"Updating build order lines for BomItem {bom_item_pk}")
|
||||||
|
|
||||||
|
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
|
||||||
|
|
||||||
|
# If the BomItem has been deleted, there is nothing to do
|
||||||
|
if not bom_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
assemblies = bom_item.get_assemblies()
|
||||||
|
|
||||||
|
# Find all active builds which reference any of the parts
|
||||||
|
builds = build.models.Build.objects.filter(
|
||||||
|
part__in=list(assemblies),
|
||||||
|
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||||
|
)
|
||||||
|
|
||||||
|
# Iterate through each build, and update the relevant line items
|
||||||
|
for bo in builds:
|
||||||
|
# Try to find a matching build order line
|
||||||
|
line = build.models.BuildLine.objects.filter(
|
||||||
|
build=bo,
|
||||||
|
bom_item=bom_item,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
q = bom_item.get_required_quantity(bo.quantity)
|
||||||
|
|
||||||
|
if line:
|
||||||
|
# Ensure quantity is correct
|
||||||
|
if line.quantity != q:
|
||||||
|
line.quantity = q
|
||||||
|
line.save()
|
||||||
|
else:
|
||||||
|
# Create a new line item
|
||||||
|
build.models.BuildLine.objects.create(
|
||||||
|
build=bo,
|
||||||
|
bom_item=bom_item,
|
||||||
|
quantity=q,
|
||||||
|
)
|
||||||
|
|
||||||
|
if builds.count() > 0:
|
||||||
|
logger.info(f"Updated {builds.count()} build orders for part {bom_item.part}")
|
||||||
|
|
||||||
|
|
||||||
def check_build_stock(build: build.models.Build):
|
def check_build_stock(build: build.models.Build):
|
||||||
"""Check the required stock for a newly created build order.
|
"""Check the required stock for a newly created build order.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ class BuildTest(BuildTestBase):
|
||||||
def test_ref_int(self):
|
def test_ref_int(self):
|
||||||
"""Test the "integer reference" field used for natural sorting"""
|
"""Test the "integer reference" field used for natural sorting"""
|
||||||
|
|
||||||
|
# Set build reference to new value
|
||||||
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
|
||||||
|
|
||||||
refs = {
|
refs = {
|
||||||
|
|
@ -168,6 +169,9 @@ class BuildTest(BuildTestBase):
|
||||||
build.save()
|
build.save()
|
||||||
self.assertEqual(build.reference_int, ref_int)
|
self.assertEqual(build.reference_int, ref_int)
|
||||||
|
|
||||||
|
# Set build reference back to default value
|
||||||
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||||
|
|
||||||
def test_ref_validation(self):
|
def test_ref_validation(self):
|
||||||
"""Test that the reference field validation works as expected"""
|
"""Test that the reference field validation works as expected"""
|
||||||
|
|
||||||
|
|
@ -214,6 +218,9 @@ class BuildTest(BuildTestBase):
|
||||||
title='Valid reference',
|
title='Valid reference',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set build reference back to default value
|
||||||
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||||
|
|
||||||
def test_next_ref(self):
|
def test_next_ref(self):
|
||||||
"""Test that the next reference is automatically generated"""
|
"""Test that the next reference is automatically generated"""
|
||||||
|
|
||||||
|
|
@ -238,6 +245,9 @@ class BuildTest(BuildTestBase):
|
||||||
self.assertEqual(build.reference, 'XYZ-000988')
|
self.assertEqual(build.reference, 'XYZ-000988')
|
||||||
self.assertEqual(build.reference_int, 988)
|
self.assertEqual(build.reference_int, 988)
|
||||||
|
|
||||||
|
# Set build reference back to default value
|
||||||
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Perform some basic tests before we start the ball rolling"""
|
"""Perform some basic tests before we start the ball rolling"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||||
|
|
||||||
do_cache = kwargs.pop('cache', True)
|
do_cache = kwargs.pop('cache', True)
|
||||||
|
|
||||||
self.clean(**kwargs)
|
self.clean()
|
||||||
self.validate_unique()
|
self.validate_unique()
|
||||||
|
|
||||||
# Execute before_save action
|
# Execute before_save action
|
||||||
|
|
@ -190,7 +190,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||||
kwargs: Keyword arguments to pass to the function
|
kwargs: Keyword arguments to pass to the function
|
||||||
"""
|
"""
|
||||||
# Get action
|
# Get action
|
||||||
setting = self.get_setting_definition(self.key, *args, **kwargs)
|
setting = self.get_setting_definition(self.key, *args, **{**self.get_filters_for_instance(), **kwargs})
|
||||||
settings_fnc = setting.get(reference, None)
|
settings_fnc = setting.get(reference, None)
|
||||||
|
|
||||||
# Execute if callable
|
# Execute if callable
|
||||||
|
|
@ -406,6 +406,17 @@ class BaseInvenTreeSetting(models.Model):
|
||||||
**cls.get_filters(**kwargs),
|
**cls.get_filters(**kwargs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Unless otherwise specified, attempt to create the setting
|
||||||
|
create = kwargs.pop('create', True)
|
||||||
|
|
||||||
|
# Prevent saving to the database during data import
|
||||||
|
if InvenTree.ready.isImportingData():
|
||||||
|
create = False
|
||||||
|
|
||||||
|
# Prevent saving to the database during migrations
|
||||||
|
if InvenTree.ready.isRunningMigrations():
|
||||||
|
create = False
|
||||||
|
|
||||||
# Perform cache lookup by default
|
# Perform cache lookup by default
|
||||||
do_cache = kwargs.pop('cache', True)
|
do_cache = kwargs.pop('cache', True)
|
||||||
|
|
||||||
|
|
@ -434,9 +445,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||||
# Setting does not exist! (Try to create it)
|
# Setting does not exist! (Try to create it)
|
||||||
if not setting:
|
if not setting:
|
||||||
|
|
||||||
# Unless otherwise specified, attempt to create the setting
|
|
||||||
create = kwargs.pop('create', True)
|
|
||||||
|
|
||||||
# Prevent creation of new settings objects when importing data
|
# Prevent creation of new settings objects when importing data
|
||||||
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
|
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
|
||||||
create = False
|
create = False
|
||||||
|
|
@ -560,7 +568,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||||
"""Return units for setting."""
|
"""Return units for setting."""
|
||||||
return self.__class__.get_setting_units(self.key, **self.get_filters_for_instance())
|
return self.__class__.get_setting_units(self.key, **self.get_filters_for_instance())
|
||||||
|
|
||||||
def clean(self, **kwargs):
|
def clean(self):
|
||||||
"""If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
|
"""If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
|
|
@ -571,7 +579,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||||
elif self.is_bool():
|
elif self.is_bool():
|
||||||
self.value = self.as_bool()
|
self.value = self.as_bool()
|
||||||
|
|
||||||
validator = self.__class__.get_setting_validator(self.key, **kwargs)
|
validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance())
|
||||||
|
|
||||||
if validator is not None:
|
if validator is not None:
|
||||||
self.run_validator(validator)
|
self.run_validator(validator)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,25 @@ from InvenTree.serializers import (InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer)
|
InvenTreeModelSerializer)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsValueField(serializers.Field):
|
||||||
|
"""Custom serializer field for a settings value."""
|
||||||
|
|
||||||
|
def get_attribute(self, instance):
|
||||||
|
"""Return the object instance, not the attribute value."""
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
"""Return the value of the setting:
|
||||||
|
|
||||||
|
- Protected settings are returned as '***'
|
||||||
|
"""
|
||||||
|
return '***' if instance.protected else str(instance.value)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
"""Return the internal value of the setting"""
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
|
||||||
class SettingsSerializer(InvenTreeModelSerializer):
|
class SettingsSerializer(InvenTreeModelSerializer):
|
||||||
"""Base serializer for a settings object."""
|
"""Base serializer for a settings object."""
|
||||||
|
|
||||||
|
|
@ -30,6 +49,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||||
|
|
||||||
api_url = serializers.CharField(read_only=True)
|
api_url = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
value = SettingsValueField()
|
||||||
|
|
||||||
def get_choices(self, obj):
|
def get_choices(self, obj):
|
||||||
"""Returns the choices available for a given item."""
|
"""Returns the choices available for a given item."""
|
||||||
results = []
|
results = []
|
||||||
|
|
@ -45,16 +66,6 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_value(self, obj):
|
|
||||||
"""Make sure protected values are not returned."""
|
|
||||||
# never return protected values
|
|
||||||
if obj.protected:
|
|
||||||
result = '***'
|
|
||||||
else:
|
|
||||||
result = obj.value
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalSettingsSerializer(SettingsSerializer):
|
class GlobalSettingsSerializer(SettingsSerializer):
|
||||||
"""Serializer for the InvenTreeSetting model."""
|
"""Serializer for the InvenTreeSetting model."""
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ def currency_code_default():
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
try:
|
try:
|
||||||
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=False, cache=False)
|
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Database may not yet be ready, no need to throw an error here
|
# Database may not yet be ready, no need to throw an error here
|
||||||
code = ''
|
code = ''
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
@ -106,6 +108,36 @@ class SettingsTest(InvenTreeTestCase):
|
||||||
self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
|
self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
|
||||||
self.assertIn('SIGNUP_GROUP', result)
|
self.assertIn('SIGNUP_GROUP', result)
|
||||||
|
|
||||||
|
@mock.patch("common.models.InvenTreeSetting.get_setting_definition")
|
||||||
|
def test_settings_validator(self, get_setting_definition):
|
||||||
|
"""Make sure that the validator function gets called on set setting."""
|
||||||
|
|
||||||
|
def validator(x):
|
||||||
|
if x == "hello":
|
||||||
|
return x
|
||||||
|
|
||||||
|
raise ValidationError(f"{x} is not valid")
|
||||||
|
|
||||||
|
mock_validator = mock.Mock(side_effect=validator)
|
||||||
|
|
||||||
|
# define partial schema
|
||||||
|
settings_definition = {
|
||||||
|
"AB": { # key that's has not already been accessed
|
||||||
|
"validator": mock_validator,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def mocked(key, **kwargs):
|
||||||
|
return settings_definition.get(key, {})
|
||||||
|
get_setting_definition.side_effect = mocked
|
||||||
|
|
||||||
|
InvenTreeSetting.set_setting("AB", "hello", self.user)
|
||||||
|
mock_validator.assert_called_with("hello")
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
InvenTreeSetting.set_setting("AB", "world", self.user)
|
||||||
|
mock_validator.assert_called_with("world")
|
||||||
|
|
||||||
def run_settings_check(self, key, setting):
|
def run_settings_check(self, key, setting):
|
||||||
"""Test that all settings are valid.
|
"""Test that all settings are valid.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q, Sum, UniqueConstraint
|
from django.db.models import Q, Sum, UniqueConstraint
|
||||||
from django.db.models.signals import post_delete, post_save, pre_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -281,10 +281,13 @@ class Address(models.Model):
|
||||||
link: External link to additional address information
|
link: External link to additional address information
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass defines extra model options"""
|
||||||
|
verbose_name_plural = "Addresses"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Custom init function"""
|
"""Custom init function"""
|
||||||
if 'confirm_primary' in kwargs:
|
|
||||||
self.confirm_primary = kwargs.pop('confirm_primary', None)
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
@ -304,25 +307,32 @@ class Address(models.Model):
|
||||||
|
|
||||||
return ", ".join(populated_lines)
|
return ", ".join(populated_lines)
|
||||||
|
|
||||||
class Meta:
|
def save(self, *args, **kwargs):
|
||||||
"""Metaclass defines extra model options"""
|
"""Run checks when saving an address:
|
||||||
verbose_name_plural = "Addresses"
|
|
||||||
|
- If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary
|
||||||
|
"""
|
||||||
|
|
||||||
|
others = list(Address.objects.filter(company=self.company).exclude(pk=self.pk).all())
|
||||||
|
|
||||||
|
# If this is the *only* address for this company, make it the primary one
|
||||||
|
if len(others) == 0:
|
||||||
|
self.primary = True
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Once this address is saved, check others
|
||||||
|
if self.primary:
|
||||||
|
for addr in others:
|
||||||
|
if addr.primary:
|
||||||
|
addr.primary = False
|
||||||
|
addr.save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
"""Return the API URL associated with the Contcat model"""
|
"""Return the API URL associated with the Contcat model"""
|
||||||
return reverse('api-address-list')
|
return reverse('api-address-list')
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
|
||||||
"""Ensure that only one primary address exists per company"""
|
|
||||||
|
|
||||||
super().validate_unique(exclude=exclude)
|
|
||||||
|
|
||||||
if self.primary:
|
|
||||||
# Check that no other primary address exists for this company
|
|
||||||
if Address.objects.filter(company=self.company, primary=True).exclude(pk=self.pk).exists():
|
|
||||||
raise ValidationError({'primary': _('Company already has a primary address')})
|
|
||||||
|
|
||||||
company = models.ForeignKey(Company, related_name='addresses',
|
company = models.ForeignKey(Company, related_name='addresses',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
verbose_name=_('Company'),
|
verbose_name=_('Company'),
|
||||||
|
|
@ -382,26 +392,6 @@ class Address(models.Model):
|
||||||
help_text=_('Link to address information (external)'))
|
help_text=_('Link to address information (external)'))
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=Address)
|
|
||||||
def check_primary(sender, instance, **kwargs):
|
|
||||||
"""Removes primary flag from current primary address if the to-be-saved address is marked as primary"""
|
|
||||||
|
|
||||||
if instance.company.primary_address is None:
|
|
||||||
instance.primary = True
|
|
||||||
|
|
||||||
# If confirm_primary is not present, this function does not need to do anything
|
|
||||||
if not hasattr(instance, 'confirm_primary') or \
|
|
||||||
instance.primary is False or \
|
|
||||||
instance.company.primary_address is None or \
|
|
||||||
instance.id == instance.company.primary_address.id:
|
|
||||||
return
|
|
||||||
|
|
||||||
if instance.confirm_primary is True:
|
|
||||||
adr = Address.objects.get(id=instance.company.primary_address.id)
|
|
||||||
adr.primary = False
|
|
||||||
adr.save()
|
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPart(MetadataMixin, models.Model):
|
class ManufacturerPart(MetadataMixin, models.Model):
|
||||||
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,8 @@ class AddressSerializer(InvenTreeModelSerializer):
|
||||||
'shipping_notes',
|
'shipping_notes',
|
||||||
'internal_shipping_notes',
|
'internal_shipping_notes',
|
||||||
'link',
|
'link',
|
||||||
'confirm_primary'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
confirm_primary = serializers.BooleanField(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class AddressBriefSerializer(InvenTreeModelSerializer):
|
class AddressBriefSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for Address Model (limited)"""
|
"""Serializer for Address Model (limited)"""
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
{% inventree_title %} | {% trans "Supplier List" %}
|
{% inventree_title %}{% if title %} | {{ title }}{% endif %}
|
||||||
{% endblock page_title %}
|
{% endblock page_title %}
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if user.is_staff and perms.company.change_company %}
|
{% if user.is_staff and perms.company.change_company %}
|
||||||
{% url 'admin:company_supplierpart_change' part.pk as url %}
|
{% url 'admin:company_manufacturerpart_change' part.pk as url %}
|
||||||
{% include "admin_button.html" with url=url %}
|
{% include "admin_button.html" with url=url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.purchase_order.change %}
|
{% if roles.purchase_order.change %}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import os
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
@ -195,40 +194,27 @@ class AddressTest(TestCase):
|
||||||
|
|
||||||
def test_primary_constraint(self):
|
def test_primary_constraint(self):
|
||||||
"""Test that there can only be one company-'primary=true' pair"""
|
"""Test that there can only be one company-'primary=true' pair"""
|
||||||
c2 = Company.objects.create(name='Test Corp2.', description='We make stuff good')
|
|
||||||
Address.objects.create(company=self.c, primary=True)
|
Address.objects.create(company=self.c, primary=True)
|
||||||
Address.objects.create(company=self.c, primary=False)
|
Address.objects.create(company=self.c, primary=False)
|
||||||
|
|
||||||
self.assertEqual(Address.objects.count(), 2)
|
self.assertEqual(Address.objects.count(), 2)
|
||||||
|
|
||||||
# Testing the constraint itself
|
self.assertTrue(Address.objects.first().primary)
|
||||||
# Intentionally throwing exceptions breaks unit tests unless performed in an atomic block
|
|
||||||
with transaction.atomic():
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
addr = Address(company=self.c, primary=True, confirm_primary=False)
|
|
||||||
addr.validate_unique()
|
|
||||||
|
|
||||||
Address.objects.create(company=c2, primary=True, line1="Hellothere", line2="generalkenobi")
|
# Create another address, specify *this* as primary
|
||||||
|
Address.objects.create(company=self.c, primary=True)
|
||||||
with transaction.atomic():
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
addr = Address(company=c2, primary=True, confirm_primary=False)
|
|
||||||
addr.validate_unique()
|
|
||||||
|
|
||||||
self.assertEqual(Address.objects.count(), 3)
|
self.assertEqual(Address.objects.count(), 3)
|
||||||
|
self.assertFalse(Address.objects.first().primary)
|
||||||
|
self.assertTrue(Address.objects.last().primary)
|
||||||
|
|
||||||
def test_first_address_is_primary(self):
|
def test_first_address_is_primary(self):
|
||||||
"""Test that first address related to company is always set to primary"""
|
"""Test that first address related to company is always set to primary"""
|
||||||
|
|
||||||
addr = Address.objects.create(company=self.c)
|
addr = Address.objects.create(company=self.c)
|
||||||
|
|
||||||
self.assertTrue(addr.primary)
|
self.assertTrue(addr.primary)
|
||||||
|
|
||||||
# Create another address, which should error out if primary is not set to False
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
addr = Address(company=self.c, primary=True)
|
|
||||||
addr.validate_unique()
|
|
||||||
|
|
||||||
def test_model_str(self):
|
def test_model_str(self):
|
||||||
"""Test value of __str__"""
|
"""Test value of __str__"""
|
||||||
t = "Test address"
|
t = "Test address"
|
||||||
|
|
|
||||||
|
|
@ -182,13 +182,15 @@ class LabelConfig(AppConfig):
|
||||||
|
|
||||||
logger.info(f"Creating entry for {model} '{label['name']}'")
|
logger.info(f"Creating entry for {model} '{label['name']}'")
|
||||||
|
|
||||||
model.objects.create(
|
try:
|
||||||
name=label['name'],
|
model.objects.create(
|
||||||
description=label['description'],
|
name=label['name'],
|
||||||
label=filename,
|
description=label['description'],
|
||||||
filters='',
|
label=filename,
|
||||||
enabled=True,
|
filters='',
|
||||||
width=label['width'],
|
enabled=True,
|
||||||
height=label['height'],
|
width=label['width'],
|
||||||
)
|
height=label['height'],
|
||||||
return
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Failed to create label '{label['name']}'")
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,14 @@ class PurchaseOrderLineItemResource(PriceResourceMixin, InvenTreeResource):
|
||||||
|
|
||||||
SKU = Field(attribute='part__SKU', readonly=True)
|
SKU = Field(attribute='part__SKU', readonly=True)
|
||||||
|
|
||||||
|
def dehydrate_purchase_price(self, line):
|
||||||
|
"""Return a string value of the 'purchase_price' field, rather than the 'Money' object"""
|
||||||
|
|
||||||
|
if line.purchase_price:
|
||||||
|
return line.purchase_price.amount
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLineResource(PriceResourceMixin, InvenTreeResource):
|
class PurchaseOrderExtraLineResource(PriceResourceMixin, InvenTreeResource):
|
||||||
"""Class for managing import / export of PurchaseOrderExtraLine data."""
|
"""Class for managing import / export of PurchaseOrderExtraLine data."""
|
||||||
|
|
|
||||||
|
|
@ -674,8 +674,8 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|
||||||
# Select location
|
# Select location (in descending order of priority)
|
||||||
loc = item.get('location', None) or item['line_item'].get_destination() or location
|
loc = location or item.get('location', None) or item['line_item'].get_destination()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
order.receive_line_item(
|
order.receive_line_item(
|
||||||
|
|
@ -879,7 +879,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Initializion routine for the serializer:
|
"""Initialization routine for the serializer:
|
||||||
|
|
||||||
- Add extra related serializer information if required
|
- Add extra related serializer information if required
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -982,9 +982,9 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||||
self.assertEqual(stock_1.count(), 1)
|
self.assertEqual(stock_1.count(), 1)
|
||||||
self.assertEqual(stock_2.count(), 1)
|
self.assertEqual(stock_2.count(), 1)
|
||||||
|
|
||||||
# Different location for each received item
|
# Same location for each received item, as overall 'location' field is provided
|
||||||
self.assertEqual(stock_1.last().location.pk, 1)
|
self.assertEqual(stock_1.last().location.pk, 1)
|
||||||
self.assertEqual(stock_2.last().location.pk, 2)
|
self.assertEqual(stock_2.last().location.pk, 1)
|
||||||
|
|
||||||
# Barcodes should have been assigned to the stock items
|
# Barcodes should have been assigned to the stock items
|
||||||
self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists())
|
self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists())
|
||||||
|
|
@ -1562,7 +1562,7 @@ class SalesOrderDownloadTest(OrderTest):
|
||||||
self.assertTrue(isinstance(file, io.BytesIO))
|
self.assertTrue(isinstance(file, io.BytesIO))
|
||||||
|
|
||||||
def test_download_csv(self):
|
def test_download_csv(self):
|
||||||
"""Tesst that the list of sales orders can be downloaded as a .csv file"""
|
"""Test that the list of sales orders can be downloaded as a .csv file"""
|
||||||
url = reverse('api-so-list')
|
url = reverse('api-so-list')
|
||||||
|
|
||||||
required_cols = [
|
required_cols = [
|
||||||
|
|
@ -1809,7 +1809,7 @@ class SalesOrderAllocateTest(OrderTest):
|
||||||
self.assertEqual(self.shipment.delivery_date, datetime(2023, 12, 5).date())
|
self.assertEqual(self.shipment.delivery_date, datetime(2023, 12, 5).date())
|
||||||
self.assertTrue(self.shipment.is_delivered())
|
self.assertTrue(self.shipment.is_delivered())
|
||||||
|
|
||||||
def test_shipment_deliverydate(self):
|
def test_shipment_delivery_date(self):
|
||||||
"""Test delivery date functions via API."""
|
"""Test delivery date functions via API."""
|
||||||
url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk})
|
url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk})
|
||||||
|
|
||||||
|
|
@ -1854,7 +1854,7 @@ class SalesOrderAllocateTest(OrderTest):
|
||||||
url = reverse('api-so-shipment-list')
|
url = reverse('api-so-shipment-list')
|
||||||
|
|
||||||
# Count before creation
|
# Count before creation
|
||||||
countbefore = models.SalesOrderShipment.objects.count()
|
count_before = models.SalesOrderShipment.objects.count()
|
||||||
|
|
||||||
# Create some new shipments via the API
|
# Create some new shipments via the API
|
||||||
for order in models.SalesOrder.objects.all():
|
for order in models.SalesOrder.objects.all():
|
||||||
|
|
@ -1885,7 +1885,7 @@ class SalesOrderAllocateTest(OrderTest):
|
||||||
# List *all* shipments
|
# List *all* shipments
|
||||||
response = self.get(url, expected_code=200)
|
response = self.get(url, expected_code=200)
|
||||||
|
|
||||||
self.assertEqual(len(response.data), countbefore + 3 * models.SalesOrder.objects.count())
|
self.assertEqual(len(response.data), count_before + 3 * models.SalesOrder.objects.count())
|
||||||
|
|
||||||
|
|
||||||
class ReturnOrderTests(InvenTreeAPITestCase):
|
class ReturnOrderTests(InvenTreeAPITestCase):
|
||||||
|
|
|
||||||
|
|
@ -2031,10 +2031,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
||||||
if bom_item.part in my_ancestors and bom_item.inherited:
|
if bom_item.part in my_ancestors and bom_item.inherited:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if already exists
|
|
||||||
if BomItem.objects.filter(part=self, sub_part=bom_item.sub_part).exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip (or throw error) if BomItem is not valid
|
# Skip (or throw error) if BomItem is not valid
|
||||||
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
|
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
|
||||||
continue
|
continue
|
||||||
|
|
@ -2350,6 +2346,8 @@ class PartPricing(common.models.MetaMixin):
|
||||||
- Detailed pricing information is very context specific in any case
|
- Detailed pricing information is very context specific in any case
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
price_modified = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_valid(self):
|
def is_valid(self):
|
||||||
"""Return True if the cached pricing is valid"""
|
"""Return True if the cached pricing is valid"""
|
||||||
|
|
@ -2476,7 +2474,7 @@ class PartPricing(common.models.MetaMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Update parent assemblies and templates
|
# Update parent assemblies and templates
|
||||||
if cascade:
|
if cascade and self.price_modified:
|
||||||
self.update_assemblies(counter)
|
self.update_assemblies(counter)
|
||||||
self.update_templates(counter)
|
self.update_templates(counter)
|
||||||
|
|
||||||
|
|
@ -2576,6 +2574,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
|
|
||||||
any_max_elements = True
|
any_max_elements = True
|
||||||
|
|
||||||
|
old_bom_cost_min = self.bom_cost_min
|
||||||
|
old_bom_cost_max = self.bom_cost_max
|
||||||
|
|
||||||
if any_min_elements:
|
if any_min_elements:
|
||||||
self.bom_cost_min = cumulative_min
|
self.bom_cost_min = cumulative_min
|
||||||
else:
|
else:
|
||||||
|
|
@ -2586,6 +2587,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
else:
|
else:
|
||||||
self.bom_cost_max = None
|
self.bom_cost_max = None
|
||||||
|
|
||||||
|
if old_bom_cost_min != self.bom_cost_min or old_bom_cost_max != self.bom_cost_max:
|
||||||
|
self.price_modified = True
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
@ -2650,6 +2654,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
if purchase_max is None or cost > purchase_max:
|
if purchase_max is None or cost > purchase_max:
|
||||||
purchase_max = cost
|
purchase_max = cost
|
||||||
|
|
||||||
|
if self.purchase_cost_min != purchase_min or self.purchase_cost_max != purchase_max:
|
||||||
|
self.price_modified = True
|
||||||
|
|
||||||
self.purchase_cost_min = purchase_min
|
self.purchase_cost_min = purchase_min
|
||||||
self.purchase_cost_max = purchase_max
|
self.purchase_cost_max = purchase_max
|
||||||
|
|
||||||
|
|
@ -2677,6 +2684,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
if max_int_cost is None or cost > max_int_cost:
|
if max_int_cost is None or cost > max_int_cost:
|
||||||
max_int_cost = cost
|
max_int_cost = cost
|
||||||
|
|
||||||
|
if self.internal_cost_min != min_int_cost or self.internal_cost_max != max_int_cost:
|
||||||
|
self.price_modified = True
|
||||||
|
|
||||||
self.internal_cost_min = min_int_cost
|
self.internal_cost_min = min_int_cost
|
||||||
self.internal_cost_max = max_int_cost
|
self.internal_cost_max = max_int_cost
|
||||||
|
|
||||||
|
|
@ -2716,6 +2726,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
if max_sup_cost is None or cost > max_sup_cost:
|
if max_sup_cost is None or cost > max_sup_cost:
|
||||||
max_sup_cost = cost
|
max_sup_cost = cost
|
||||||
|
|
||||||
|
if self.supplier_price_min != min_sup_cost or self.supplier_price_max != max_sup_cost:
|
||||||
|
self.price_modified = True
|
||||||
|
|
||||||
self.supplier_price_min = min_sup_cost
|
self.supplier_price_min = min_sup_cost
|
||||||
self.supplier_price_max = max_sup_cost
|
self.supplier_price_max = max_sup_cost
|
||||||
|
|
||||||
|
|
@ -2753,6 +2766,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
if variant_max is None or v_max > variant_max:
|
if variant_max is None or v_max > variant_max:
|
||||||
variant_max = v_max
|
variant_max = v_max
|
||||||
|
|
||||||
|
if self.variant_cost_min != variant_min or self.variant_cost_max != variant_max:
|
||||||
|
self.price_modified = True
|
||||||
|
|
||||||
self.variant_cost_min = variant_min
|
self.variant_cost_min = variant_min
|
||||||
self.variant_cost_max = variant_max
|
self.variant_cost_max = variant_max
|
||||||
|
|
||||||
|
|
@ -2877,6 +2893,9 @@ class PartPricing(common.models.MetaMixin):
|
||||||
if max_sell_history is None or cost > max_sell_history:
|
if max_sell_history is None or cost > max_sell_history:
|
||||||
max_sell_history = cost
|
max_sell_history = cost
|
||||||
|
|
||||||
|
if self.sale_history_min != min_sell_history or self.sale_history_max != max_sell_history:
|
||||||
|
self.price_modified = True
|
||||||
|
|
||||||
self.sale_history_min = min_sell_history
|
self.sale_history_min = min_sell_history
|
||||||
self.sale_history_max = max_sell_history
|
self.sale_history_max = max_sell_history
|
||||||
|
|
||||||
|
|
@ -3532,15 +3551,6 @@ class PartParameter(MetadataMixin, models.Model):
|
||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Validate the parameter data against the template units
|
|
||||||
if self.template.units:
|
|
||||||
try:
|
|
||||||
InvenTree.conversion.convert_physical_value(self.data, self.template.units)
|
|
||||||
except ValidationError as e:
|
|
||||||
raise ValidationError({
|
|
||||||
'data': e.message
|
|
||||||
})
|
|
||||||
|
|
||||||
# Validate the parameter data against the template choices
|
# Validate the parameter data against the template choices
|
||||||
if choices := self.template.get_choices():
|
if choices := self.template.get_choices():
|
||||||
if self.data not in choices:
|
if self.data not in choices:
|
||||||
|
|
@ -3749,6 +3759,18 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
|
||||||
"""Return the list API endpoint URL associated with the BomItem model"""
|
"""Return the list API endpoint URL associated with the BomItem model"""
|
||||||
return reverse('api-bom-list')
|
return reverse('api-bom-list')
|
||||||
|
|
||||||
|
def get_assemblies(self):
|
||||||
|
"""Return a list of assemblies which use this BomItem"""
|
||||||
|
|
||||||
|
assemblies = [self.part]
|
||||||
|
|
||||||
|
if self.inherited:
|
||||||
|
assemblies += list(
|
||||||
|
self.part.get_descendants(include_self=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
return assemblies
|
||||||
|
|
||||||
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
|
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
|
||||||
"""Return a list of valid parts which can be allocated against this BomItem.
|
"""Return a list of valid parts which can be allocated against this BomItem.
|
||||||
|
|
||||||
|
|
@ -4048,6 +4070,18 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
|
||||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=BomItem, dispatch_uid='update_bom_build_lines')
|
||||||
|
def update_bom_build_lines(sender, instance, created, **kwargs):
|
||||||
|
"""Update existing build orders when a BomItem is created or edited"""
|
||||||
|
|
||||||
|
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||||
|
import build.tasks
|
||||||
|
InvenTree.tasks.offload_task(
|
||||||
|
build.tasks.update_build_order_lines,
|
||||||
|
instance.pk
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item')
|
@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item')
|
||||||
@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break')
|
@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break')
|
||||||
@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break')
|
@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break')
|
||||||
|
|
|
||||||
|
|
@ -314,10 +314,6 @@ def generate_stocktake_report(**kwargs):
|
||||||
# Create a new stocktake for this part (do not commit, this will take place later on)
|
# Create a new stocktake for this part (do not commit, this will take place later on)
|
||||||
stocktake = perform_stocktake(p, user, commit=False)
|
stocktake = perform_stocktake(p, user, commit=False)
|
||||||
|
|
||||||
if stocktake.quantity == 0:
|
|
||||||
# Skip rows with zero total quantity
|
|
||||||
continue
|
|
||||||
|
|
||||||
total_parts += 1
|
total_parts += 1
|
||||||
|
|
||||||
stocktake_instances.append(stocktake)
|
stocktake_instances.append(stocktake)
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,13 @@ def sso_check_provider(provider):
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
|
||||||
# First, check that the provider is enabled
|
# First, check that the provider is enabled
|
||||||
apps = SocialApp.objects.filter(provider__iexact=provider.name)
|
apps = SocialApp.objects.filter(provider__iexact=provider.id)
|
||||||
|
|
||||||
if not apps.exists():
|
if not apps.exists():
|
||||||
|
logging.error(
|
||||||
|
"SSO SocialApp %s does not exist (known providers: %s)",
|
||||||
|
provider.id, [obj.provider for obj in SocialApp.objects.all()]
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Next, check that the provider is correctly configured
|
# Next, check that the provider is correctly configured
|
||||||
|
|
|
||||||
|
|
@ -2966,7 +2966,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
|
||||||
|
|
||||||
data = response.data[0]
|
data = response.data[0]
|
||||||
|
|
||||||
self.assertEqual(data['part_count'], 8)
|
self.assertEqual(data['part_count'], 14)
|
||||||
self.assertEqual(data['user'], None)
|
self.assertEqual(data['user'], None)
|
||||||
self.assertTrue(data['report'].endswith('.csv'))
|
self.assertTrue(data['report'].endswith('.csv'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,12 +168,6 @@ class ParameterTests(TestCase):
|
||||||
param = PartParameter(part=prt, template=template, data=value)
|
param = PartParameter(part=prt, template=template, data=value)
|
||||||
param.full_clean()
|
param.full_clean()
|
||||||
|
|
||||||
# Test that invalid parameters fail
|
|
||||||
for value in ['3 Amps', '-3 zogs', '3.14F']:
|
|
||||||
param = PartParameter(part=prt, template=template, data=value)
|
|
||||||
with self.assertRaises(django_exceptions.ValidationError):
|
|
||||||
param.full_clean()
|
|
||||||
|
|
||||||
def test_param_unit_conversion(self):
|
def test_param_unit_conversion(self):
|
||||||
"""Test that parameters are correctly converted to template units"""
|
"""Test that parameters are correctly converted to template units"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,11 @@ class PartImport(FileManagementFormView):
|
||||||
for row in self.rows:
|
for row in self.rows:
|
||||||
# check each submitted column
|
# check each submitted column
|
||||||
for idx in col_ids:
|
for idx in col_ids:
|
||||||
data = row['data'][col_ids[idx]]['cell']
|
|
||||||
|
try:
|
||||||
|
data = row['data'][col_ids[idx]]['cell']
|
||||||
|
except (IndexError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,12 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||||
# Save plugin
|
# Save plugin
|
||||||
self.plugin: InvenTreePlugin = plugin
|
self.plugin: InvenTreePlugin = plugin
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
"""Customize pickeling behaviour."""
|
||||||
|
state = super().__getstate__()
|
||||||
|
state.pop("plugin", None) # plugin cannot be pickelt in some circumstances when used with drf views, remove it (#5408)
|
||||||
|
return state
|
||||||
|
|
||||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||||
"""Extend save method to reload plugins if the 'active' status changes."""
|
"""Extend save method to reload plugins if the 'active' status changes."""
|
||||||
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
"""Sample implementations for IntegrationPlugin."""
|
"""Sample implementations for IntegrationPlugin."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -8,6 +11,14 @@ from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import AppMixin, NavigationMixin, SettingsMixin, UrlsMixin
|
from plugin.mixins import AppMixin, NavigationMixin, SettingsMixin, UrlsMixin
|
||||||
|
|
||||||
|
|
||||||
|
def validate_json(value):
|
||||||
|
"""Example validator for json input."""
|
||||||
|
try:
|
||||||
|
json.loads(value)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
|
||||||
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin):
|
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin):
|
||||||
"""A full plugin example."""
|
"""A full plugin example."""
|
||||||
|
|
||||||
|
|
@ -72,6 +83,17 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
|
||||||
'description': 'Select a part object from the database',
|
'description': 'Select a part object from the database',
|
||||||
'model': 'part.part',
|
'model': 'part.part',
|
||||||
},
|
},
|
||||||
|
'PROTECTED_SETTING': {
|
||||||
|
'name': 'Protected Setting',
|
||||||
|
'description': 'A protected setting, hidden from the UI',
|
||||||
|
'default': 'ABC-123',
|
||||||
|
'protected': True,
|
||||||
|
},
|
||||||
|
'VALIDATOR_SETTING': {
|
||||||
|
'name': 'JSON validator Setting',
|
||||||
|
'description': 'A setting using a JSON validator',
|
||||||
|
'validator': validate_json,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NAVIGATION = [
|
NAVIGATION = [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
"""Unit tests for action plugins."""
|
"""Unit tests for action plugins."""
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from InvenTree.unit_test import InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
|
from plugin import registry
|
||||||
|
|
||||||
|
|
||||||
class SampleIntegrationPluginTests(InvenTreeTestCase):
|
class SampleIntegrationPluginTests(InvenTreeTestCase):
|
||||||
|
|
@ -11,3 +14,17 @@ class SampleIntegrationPluginTests(InvenTreeTestCase):
|
||||||
response = self.client.get('/plugin/sample/ho/he/')
|
response = self.client.get('/plugin/sample/ho/he/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'Hi there testuser this works')
|
self.assertEqual(response.content, b'Hi there testuser this works')
|
||||||
|
|
||||||
|
def test_settings_validator(self):
|
||||||
|
"""Test settings validator for plugins."""
|
||||||
|
|
||||||
|
plugin = registry.get_plugin('sample')
|
||||||
|
valid_json = '{"ts": 13}'
|
||||||
|
not_valid_json = '{"ts""13"}'
|
||||||
|
|
||||||
|
# no error, should pass validator
|
||||||
|
plugin.set_setting('VALIDATOR_SETTING', valid_json)
|
||||||
|
|
||||||
|
# should throw an error
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
plugin.set_setting('VALIDATOR_SETTING', not_valid_json)
|
||||||
|
|
|
||||||
|
|
@ -153,13 +153,29 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
||||||
success = False
|
success = False
|
||||||
# execute pypi
|
# execute pypi
|
||||||
try:
|
try:
|
||||||
result = subprocess.check_output(command, cwd=settings.BASE_DIR.parent)
|
result = subprocess.check_output(command, cwd=settings.BASE_DIR.parent, stderr=subprocess.STDOUT)
|
||||||
ret['result'] = str(result, 'utf-8')
|
ret['result'] = str(result, 'utf-8')
|
||||||
ret['success'] = True
|
ret['success'] = True
|
||||||
|
ret['error'] = False
|
||||||
success = True
|
success = True
|
||||||
except subprocess.CalledProcessError as error: # pragma: no cover
|
except subprocess.CalledProcessError as error: # pragma: no cover
|
||||||
ret['result'] = str(error.output, 'utf-8')
|
output = error.output.decode('utf-8')
|
||||||
ret['error'] = True
|
|
||||||
|
# Raise a ValidationError as the plugin install failed
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for msg in output.split('\n'):
|
||||||
|
msg = msg.strip()
|
||||||
|
if msg:
|
||||||
|
errors.append(msg)
|
||||||
|
|
||||||
|
if len(errors) == 0:
|
||||||
|
errors.append(_('Unknown error'))
|
||||||
|
|
||||||
|
if len(errors) > 1:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
else:
|
||||||
|
raise ValidationError(errors[0])
|
||||||
|
|
||||||
# save plugin to plugin_file if installed successful
|
# save plugin to plugin_file if installed successful
|
||||||
if success:
|
if success:
|
||||||
|
|
|
||||||
|
|
@ -193,3 +193,76 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||||
with self.assertRaises(NotFound) as exc:
|
with self.assertRaises(NotFound) as exc:
|
||||||
check_plugin(plugin_slug=None, plugin_pk='123')
|
check_plugin(plugin_slug=None, plugin_pk='123')
|
||||||
self.assertEqual(str(exc.exception.detail), "Plugin '123' not installed")
|
self.assertEqual(str(exc.exception.detail), "Plugin '123' not installed")
|
||||||
|
|
||||||
|
def test_plugin_settings(self):
|
||||||
|
"""Test plugin settings access via the API"""
|
||||||
|
|
||||||
|
# Ensure we have superuser permissions
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
# Activate the 'sample' plugin via the API
|
||||||
|
cfg = PluginConfig.objects.filter(key='sample').first()
|
||||||
|
url = reverse('api-plugin-detail-activate', kwargs={'pk': cfg.pk})
|
||||||
|
self.client.patch(url, {}, expected_code=200)
|
||||||
|
|
||||||
|
# Valid plugin settings endpoints
|
||||||
|
valid_settings = [
|
||||||
|
'SELECT_PART',
|
||||||
|
'API_KEY',
|
||||||
|
'NUMERICAL_SETTING',
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in valid_settings:
|
||||||
|
response = self.get(
|
||||||
|
reverse('api-plugin-setting-detail', kwargs={
|
||||||
|
'plugin': 'sample',
|
||||||
|
'key': key
|
||||||
|
}))
|
||||||
|
|
||||||
|
self.assertEqual(response.data['key'], key)
|
||||||
|
|
||||||
|
# Test that an invalid setting key raises a 404 error
|
||||||
|
response = self.get(
|
||||||
|
reverse('api-plugin-setting-detail', kwargs={
|
||||||
|
'plugin': 'sample',
|
||||||
|
'key': 'INVALID_SETTING'
|
||||||
|
}),
|
||||||
|
expected_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test that a protected setting returns hidden value
|
||||||
|
response = self.get(
|
||||||
|
reverse('api-plugin-setting-detail', kwargs={
|
||||||
|
'plugin': 'sample',
|
||||||
|
'key': 'PROTECTED_SETTING'
|
||||||
|
}),
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.data['value'], '***')
|
||||||
|
|
||||||
|
# Test that we can update a setting value
|
||||||
|
response = self.patch(
|
||||||
|
reverse('api-plugin-setting-detail', kwargs={
|
||||||
|
'plugin': 'sample',
|
||||||
|
'key': 'NUMERICAL_SETTING'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
'value': 456
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.data['value'], '456')
|
||||||
|
|
||||||
|
# Retrieve the value again
|
||||||
|
response = self.get(
|
||||||
|
reverse('api-plugin-setting-detail', kwargs={
|
||||||
|
'plugin': 'sample',
|
||||||
|
'key': 'NUMERICAL_SETTING'
|
||||||
|
}),
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.data['value'], '456')
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import InvenTree.helpers
|
||||||
import order.models
|
import order.models
|
||||||
import part.models
|
import part.models
|
||||||
from InvenTree.api import MetadataView
|
from InvenTree.api import MetadataView
|
||||||
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.filters import InvenTreeSearchFilter
|
from InvenTree.filters import InvenTreeSearchFilter
|
||||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
|
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
|
||||||
from stock.models import StockItem, StockItemAttachment
|
from stock.models import StockItem, StockItemAttachment
|
||||||
|
|
@ -181,78 +182,90 @@ class ReportPrintMixin:
|
||||||
# Start with a default report name
|
# Start with a default report name
|
||||||
report_name = "report.pdf"
|
report_name = "report.pdf"
|
||||||
|
|
||||||
# Merge one or more PDF files into a single download
|
try:
|
||||||
for item in items_to_print:
|
# Merge one or more PDF files into a single download
|
||||||
report = self.get_object()
|
for item in items_to_print:
|
||||||
report.object_to_print = item
|
report = self.get_object()
|
||||||
|
report.object_to_print = item
|
||||||
|
|
||||||
report_name = report.generate_filename(request)
|
report_name = report.generate_filename(request)
|
||||||
output = report.render(request)
|
output = report.render(request)
|
||||||
|
|
||||||
# Run report callback for each generated report
|
# Run report callback for each generated report
|
||||||
self.report_callback(item, output, request)
|
self.report_callback(item, output, request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
outputs.append(report.render_as_string(request))
|
outputs.append(report.render_as_string(request))
|
||||||
else:
|
else:
|
||||||
outputs.append(output)
|
outputs.append(output)
|
||||||
except TemplateDoesNotExist as e:
|
except TemplateDoesNotExist as e:
|
||||||
template = str(e)
|
template = str(e)
|
||||||
if not template:
|
if not template:
|
||||||
template = report.template
|
template = report.template
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
'error': _(f"Template file '{template}' is missing or does not exist"),
|
'error': _(f"Template file '{template}' is missing or does not exist"),
|
||||||
},
|
},
|
||||||
status=400,
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not report_name.endswith('.pdf'):
|
||||||
|
report_name += '.pdf'
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
|
||||||
|
|
||||||
|
html = "\n".join(outputs)
|
||||||
|
|
||||||
|
return HttpResponse(html)
|
||||||
|
else:
|
||||||
|
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for output in outputs:
|
||||||
|
doc = output.get_document()
|
||||||
|
for page in doc.pages:
|
||||||
|
pages.append(page)
|
||||||
|
|
||||||
|
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||||
|
|
||||||
|
except TemplateDoesNotExist as e:
|
||||||
|
|
||||||
|
template = str(e)
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
template = report.template
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error': _(f"Template file '{template}' is missing or does not exist"),
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user, cache=False)
|
||||||
|
|
||||||
|
return InvenTree.helpers.DownloadFile(
|
||||||
|
pdf,
|
||||||
|
report_name,
|
||||||
|
content_type='application/pdf',
|
||||||
|
inline=inline,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not report_name.endswith('.pdf'):
|
except Exception as exc:
|
||||||
report_name += '.pdf'
|
# Log the exception to the database
|
||||||
|
log_error(request.path)
|
||||||
|
|
||||||
if debug_mode:
|
# Re-throw the exception to the client as a DRF exception
|
||||||
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
|
raise ValidationError({
|
||||||
|
'error': 'Report printing failed',
|
||||||
html = "\n".join(outputs)
|
'detail': str(exc),
|
||||||
|
'path': request.path,
|
||||||
return HttpResponse(html)
|
})
|
||||||
else:
|
|
||||||
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
|
|
||||||
|
|
||||||
pages = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
for output in outputs:
|
|
||||||
doc = output.get_document()
|
|
||||||
for page in doc.pages:
|
|
||||||
pages.append(page)
|
|
||||||
|
|
||||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
|
||||||
|
|
||||||
except TemplateDoesNotExist as e:
|
|
||||||
|
|
||||||
template = str(e)
|
|
||||||
|
|
||||||
if not template:
|
|
||||||
template = report.template
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
'error': _(f"Template file '{template}' is missing or does not exist"),
|
|
||||||
},
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user, cache=False)
|
|
||||||
|
|
||||||
return InvenTree.helpers.DownloadFile(
|
|
||||||
pdf,
|
|
||||||
report_name,
|
|
||||||
content_type='application/pdf',
|
|
||||||
inline=inline,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Default implementation of GET for a print endpoint.
|
"""Default implementation of GET for a print endpoint.
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,11 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||||
if bool(data.get('use_pack_size')):
|
if bool(data.get('use_pack_size')):
|
||||||
quantity = data['quantity'] = supplier_part.base_quantity(quantity)
|
quantity = data['quantity'] = supplier_part.base_quantity(quantity)
|
||||||
# Divide purchase price by pack size, to save correct price per stock item
|
# Divide purchase price by pack size, to save correct price per stock item
|
||||||
data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_quantity_native)
|
try:
|
||||||
|
data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_quantity_native)
|
||||||
|
except ValueError:
|
||||||
|
# If the purchase price is not a number, ignore it
|
||||||
|
pass
|
||||||
|
|
||||||
# Now remove the flag from data, so that it doesn't interfere with saving
|
# Now remove the flag from data, so that it doesn't interfere with saving
|
||||||
# Do this regardless of results above
|
# Do this regardless of results above
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ def fix_purchase_price(apps, schema_editor):
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
('company', '0047_supplierpart_pack_size'),
|
||||||
('stock', '0093_auto_20230217_2140'),
|
('stock', '0093_auto_20230217_2140'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,12 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-th-list'></span></td>
|
<td><span class='fas fa-th-list'></span></td>
|
||||||
<td>{% trans "Sales Order" %}</td>
|
<td>{% trans "Sales Order" %}</td>
|
||||||
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
|
<td>
|
||||||
|
<a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a>
|
||||||
|
{% if item.sales_order.customer %}
|
||||||
|
- <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if allocated_to_sales_orders %}
|
{% if allocated_to_sales_orders %}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@
|
||||||
{{ setting.description }}
|
{{ setting.description }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if setting.is_bool %}
|
{% if setting.protected %}
|
||||||
|
<span style='color: red;'>***</span> <span class='fas fa-lock icon-red'></span>
|
||||||
|
{% elif setting.is_bool %}
|
||||||
{% include "InvenTree/settings/setting_boolean.html" %}
|
{% include "InvenTree/settings/setting_boolean.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div id='setting-{{ setting.pk }}'>
|
<div id='setting-{{ setting.pk }}'>
|
||||||
|
|
|
||||||
|
|
@ -281,10 +281,20 @@ function loadAttachmentTable(url, options) {
|
||||||
sidePagination: 'server',
|
sidePagination: 'server',
|
||||||
onPostBody: function() {
|
onPostBody: function() {
|
||||||
|
|
||||||
|
// Add callback for 'delete' button
|
||||||
|
if (permissions.delete) {
|
||||||
|
$(table).find('.button-attachment-delete').click(function() {
|
||||||
|
let pk = $(this).attr('pk');
|
||||||
|
let attachments = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||||
|
|
||||||
|
deleteAttachments([attachments], url, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add callback for 'edit' button
|
// Add callback for 'edit' button
|
||||||
if (permissions.change) {
|
if (permissions.change) {
|
||||||
$(table).find('.button-attachment-edit').click(function() {
|
$(table).find('.button-attachment-edit').click(function() {
|
||||||
var pk = $(this).attr('pk');
|
let pk = $(this).attr('pk');
|
||||||
|
|
||||||
constructForm(`${url}${pk}/`, {
|
constructForm(`${url}${pk}/`, {
|
||||||
fields: {
|
fields: {
|
||||||
|
|
|
||||||
|
|
@ -905,6 +905,18 @@ function loadBomTable(table, options={}) {
|
||||||
title: '{% trans "Part" %}',
|
title: '{% trans "Part" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
|
sorter: function(_valA, _valB, rowA, rowB) {
|
||||||
|
let name_a = rowA.sub_part_detail.full_name;
|
||||||
|
let name_b = rowB.sub_part_detail.full_name;
|
||||||
|
|
||||||
|
if (name_a > name_b) {
|
||||||
|
return 1;
|
||||||
|
} else if (name_a < name_b) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
var url = `/part/${row.sub_part}/`;
|
var url = `/part/${row.sub_part}/`;
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
|
||||||
|
|
@ -2173,9 +2173,6 @@ function loadBuildTable(table, options) {
|
||||||
customView: function(data) {
|
customView: function(data) {
|
||||||
return `<div id='build-order-calendar'></div>`;
|
return `<div id='build-order-calendar'></div>`;
|
||||||
},
|
},
|
||||||
onRefresh: function() {
|
|
||||||
loadBuildTable(table, options);
|
|
||||||
},
|
|
||||||
onLoadSuccess: function() {
|
onLoadSuccess: function() {
|
||||||
|
|
||||||
if (tree_enable) {
|
if (tree_enable) {
|
||||||
|
|
@ -2255,16 +2252,16 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
||||||
{
|
{
|
||||||
field: 'part',
|
field: 'part',
|
||||||
title: '{% trans "Part" %}',
|
title: '{% trans "Part" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(_value, row) {
|
||||||
let html = imageHoverIcon(row.part_detail.thumbnail);
|
let html = imageHoverIcon(row.part_detail.thumbnail);
|
||||||
html += renderLink(row.part_detail.full_name, `/part/${value}/`);
|
html += renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'quantity',
|
field: 'quantity',
|
||||||
title: '{% trans "Allocated Quantity" %}',
|
title: '{% trans "Allocated Quantity" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(_value, row) {
|
||||||
let text = '';
|
let text = '';
|
||||||
let url = '';
|
let url = '';
|
||||||
let serial = row.serial;
|
let serial = row.serial;
|
||||||
|
|
@ -2294,8 +2291,8 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
|
||||||
title: '{% trans "Location" %}',
|
title: '{% trans "Location" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
if (row.location_detail) {
|
if (row.location_detail) {
|
||||||
var text = shortenString(row.location_detail.pathstring);
|
let text = shortenString(row.location_detail.pathstring);
|
||||||
var url = `/stock/location/${row.location}/`;
|
let url = `/stock/location/${row.location_detail.pk}/`;
|
||||||
|
|
||||||
return renderLink(text, url);
|
return renderLink(text, url);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2660,6 +2657,7 @@ function loadBuildLineTable(table, build_id, options={}) {
|
||||||
|
|
||||||
deallocateStock(build_id, {
|
deallocateStock(build_id, {
|
||||||
build_line: pk,
|
build_line: pk,
|
||||||
|
output: output,
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
$(table).bootstrapTable('refresh');
|
$(table).bootstrapTable('refresh');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
showFormInput,
|
showFormInput,
|
||||||
thumbnailImage,
|
thumbnailImage,
|
||||||
wrapButtons,
|
wrapButtons,
|
||||||
|
yesNoLabel,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
|
@ -798,45 +799,7 @@ function addressFields(options={}) {
|
||||||
company: {
|
company: {
|
||||||
icon: 'fa-building',
|
icon: 'fa-building',
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {},
|
||||||
onEdit: function(val, name, field, opts) {
|
|
||||||
|
|
||||||
if (val === false) {
|
|
||||||
|
|
||||||
hideFormInput("confirm_primary", opts);
|
|
||||||
$('#id_confirm_primary').prop("checked", false);
|
|
||||||
clearFormErrors(opts);
|
|
||||||
enableSubmitButton(opts, true);
|
|
||||||
|
|
||||||
} else if (val === true) {
|
|
||||||
|
|
||||||
showFormInput("confirm_primary", opts);
|
|
||||||
if($('#id_confirm_primary').prop("checked") === false) {
|
|
||||||
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
|
|
||||||
enableSubmitButton(opts, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirm_primary: {
|
|
||||||
help_text: "Confirm",
|
|
||||||
onEdit: function(val, name, field, opts) {
|
|
||||||
|
|
||||||
if (val === true) {
|
|
||||||
|
|
||||||
clearFormErrors(opts);
|
|
||||||
enableSubmitButton(opts, true);
|
|
||||||
|
|
||||||
} else if (val === false) {
|
|
||||||
|
|
||||||
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
|
|
||||||
enableSubmitButton(opts, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
css: {
|
|
||||||
display: 'none'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: {},
|
title: {},
|
||||||
line1: {
|
line1: {
|
||||||
icon: 'fa-map'
|
icon: 'fa-map'
|
||||||
|
|
@ -984,11 +947,7 @@ function loadAddressTable(table, options={}) {
|
||||||
title: '{% trans "Primary" %}',
|
title: '{% trans "Primary" %}',
|
||||||
switchable: false,
|
switchable: false,
|
||||||
formatter: function(value) {
|
formatter: function(value) {
|
||||||
let checked = '';
|
return yesNoLabel(value);
|
||||||
if (value == true) {
|
|
||||||
checked = 'checked="checked"';
|
|
||||||
}
|
|
||||||
return `<input type="checkbox" ${checked} disabled="disabled" value="${value? 1 : 0}">`;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1404,6 +1404,7 @@ function createPartParameter(part_id, options={}) {
|
||||||
function editPartParameter(param_id, options={}) {
|
function editPartParameter(param_id, options={}) {
|
||||||
options.fields = partParameterFields();
|
options.fields = partParameterFields();
|
||||||
options.title = '{% trans "Edit Parameter" %}';
|
options.title = '{% trans "Edit Parameter" %}';
|
||||||
|
options.focus = 'data';
|
||||||
|
|
||||||
options.processBeforeUpload = function(data) {
|
options.processBeforeUpload = function(data) {
|
||||||
// Convert data to string
|
// Convert data to string
|
||||||
|
|
@ -2367,6 +2368,38 @@ function loadPartTable(table, url, options={}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
},
|
||||||
|
footerFormatter: function(data) {
|
||||||
|
// Display "total" stock quantity of all rendered rows
|
||||||
|
// Requires that all parts have the same base units!
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let units = new Set();
|
||||||
|
|
||||||
|
data.forEach(function(row) {
|
||||||
|
units.add(row.units || null);
|
||||||
|
if (row.total_in_stock != null) {
|
||||||
|
total += row.total_in_stock;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length == 0) {
|
||||||
|
return '-';
|
||||||
|
} else if (units.size > 1) {
|
||||||
|
return '-';
|
||||||
|
} else {
|
||||||
|
let output = `${total}`;
|
||||||
|
|
||||||
|
if (units.size == 1) {
|
||||||
|
let unit = units.values().next().value;
|
||||||
|
|
||||||
|
if (unit) {
|
||||||
|
output += ` [${unit}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2442,6 +2475,7 @@ function loadPartTable(table, url, options={}) {
|
||||||
showColumns: true,
|
showColumns: true,
|
||||||
showCustomView: grid_view,
|
showCustomView: grid_view,
|
||||||
showCustomViewButton: false,
|
showCustomViewButton: false,
|
||||||
|
showFooter: true,
|
||||||
onPostBody: function() {
|
onPostBody: function() {
|
||||||
grid_view = inventreeLoad('part-grid-view') == 1;
|
grid_view = inventreeLoad('part-grid-view') == 1;
|
||||||
if (grid_view) {
|
if (grid_view) {
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ function loadBomPricingChart(options={}) {
|
||||||
var part = options.part;
|
var part = options.part;
|
||||||
|
|
||||||
if (!part) {
|
if (!part) {
|
||||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
console.error('No part provided to loadBomPricingChart');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,7 +434,7 @@ function loadPartSupplierPricingTable(options={}) {
|
||||||
var part = options.part;
|
var part = options.part;
|
||||||
|
|
||||||
if (!part) {
|
if (!part) {
|
||||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
console.error('No part provided to loadPartSupplierPricingTable');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -764,7 +764,21 @@ function loadPurchasePriceHistoryTable(options={}) {
|
||||||
data = data.sort((a, b) => (a.order_detail.complete_date - b.order_detail.complete_date));
|
data = data.sort((a, b) => (a.order_detail.complete_date - b.order_detail.complete_date));
|
||||||
|
|
||||||
var graphLabels = Array.from(data, (x) => (`${x.order_detail.reference} - ${x.order_detail.complete_date}`));
|
var graphLabels = Array.from(data, (x) => (`${x.order_detail.reference} - ${x.order_detail.complete_date}`));
|
||||||
var graphValues = Array.from(data, (x) => (x.purchase_price / x.supplier_part_detail.pack_size));
|
var graphValues = Array.from(data, (x) => {
|
||||||
|
let pp = x.purchase_price;
|
||||||
|
|
||||||
|
let div = 1.0;
|
||||||
|
|
||||||
|
if (x.supplier_part_detail) {
|
||||||
|
div = parseFloat(x.supplier_part_detail.pack_quantity_native);
|
||||||
|
|
||||||
|
if (isNaN(div) || !isFinite(div)) {
|
||||||
|
div = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pp / div;
|
||||||
|
});
|
||||||
|
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy();
|
chart.destroy();
|
||||||
|
|
|
||||||
|
|
@ -1759,9 +1759,6 @@ function loadPurchaseOrderTable(table, options) {
|
||||||
customView: function(data) {
|
customView: function(data) {
|
||||||
return `<div id='purchase-order-calendar'></div>`;
|
return `<div id='purchase-order-calendar'></div>`;
|
||||||
},
|
},
|
||||||
onRefresh: function() {
|
|
||||||
loadPurchaseOrderTable(table, options);
|
|
||||||
},
|
|
||||||
onLoadSuccess: function() {
|
onLoadSuccess: function() {
|
||||||
|
|
||||||
if (display_mode == 'calendar') {
|
if (display_mode == 'calendar') {
|
||||||
|
|
|
||||||
|
|
@ -262,9 +262,6 @@ function loadReturnOrderTable(table, options={}) {
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return '{% trans "No return orders found" %}';
|
return '{% trans "No return orders found" %}';
|
||||||
},
|
},
|
||||||
onRefresh: function() {
|
|
||||||
loadReturnOrderTable(table, options);
|
|
||||||
},
|
|
||||||
onLoadSuccess: function() {
|
onLoadSuccess: function() {
|
||||||
// TODO
|
// TODO
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -735,9 +735,6 @@ function loadSalesOrderTable(table, options) {
|
||||||
customView: function(data) {
|
customView: function(data) {
|
||||||
return `<div id='purchase-order-calendar'></div>`;
|
return `<div id='purchase-order-calendar'></div>`;
|
||||||
},
|
},
|
||||||
onRefresh: function() {
|
|
||||||
loadSalesOrderTable(table, options);
|
|
||||||
},
|
|
||||||
onLoadSuccess: function() {
|
onLoadSuccess: function() {
|
||||||
|
|
||||||
if (display_mode == 'calendar') {
|
if (display_mode == 'calendar') {
|
||||||
|
|
|
||||||
|
|
@ -2068,13 +2068,36 @@ function loadStockTable(table, options) {
|
||||||
// Display "total" stock quantity of all rendered rows
|
// Display "total" stock quantity of all rendered rows
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
|
// Keep track of the whether all units are the same
|
||||||
|
// If different units are found, we cannot aggregate the quantities
|
||||||
|
let units = new Set();
|
||||||
|
|
||||||
data.forEach(function(row) {
|
data.forEach(function(row) {
|
||||||
|
|
||||||
|
units.add(row.part_detail.units || null);
|
||||||
|
|
||||||
if (row.quantity != null) {
|
if (row.quantity != null) {
|
||||||
total += row.quantity;
|
total += row.quantity;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return total;
|
if (data.length == 0) {
|
||||||
|
return '-';
|
||||||
|
} else if (units.size > 1) {
|
||||||
|
return '-';
|
||||||
|
} else {
|
||||||
|
let output = `${total}`;
|
||||||
|
|
||||||
|
if (units.size == 1) {
|
||||||
|
let unit = units.values().next().value;
|
||||||
|
|
||||||
|
if (unit) {
|
||||||
|
output += ` [${unit}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2352,6 +2375,10 @@ function loadStockTable(table, options) {
|
||||||
let row = table.bootstrapTable('getRowByUniqueId', stock_item);
|
let row = table.bootstrapTable('getRowByUniqueId', stock_item);
|
||||||
row.installed_items_received = true;
|
row.installed_items_received = true;
|
||||||
|
|
||||||
|
for (let ii = 0; ii < response.length; ii++) {
|
||||||
|
response[ii].belongs_to_item = stock_item;
|
||||||
|
}
|
||||||
|
|
||||||
table.bootstrapTable('updateByUniqueId', stock_item, row, true);
|
table.bootstrapTable('updateByUniqueId', stock_item, row, true);
|
||||||
table.bootstrapTable('append', response);
|
table.bootstrapTable('append', response);
|
||||||
|
|
||||||
|
|
@ -2367,6 +2394,7 @@ function loadStockTable(table, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let parent_id = 'top-level';
|
let parent_id = 'top-level';
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
table.inventreeTable({
|
table.inventreeTable({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
@ -2382,13 +2410,25 @@ function loadStockTable(table, options) {
|
||||||
showFooter: true,
|
showFooter: true,
|
||||||
columns: columns,
|
columns: columns,
|
||||||
treeEnable: show_installed_items,
|
treeEnable: show_installed_items,
|
||||||
rootParentId: parent_id,
|
rootParentId: show_installed_items ? parent_id : null,
|
||||||
parentIdField: 'belongs_to',
|
parentIdField: show_installed_items ? 'belongs_to_item' : null,
|
||||||
uniqueId: 'pk',
|
uniqueId: 'pk',
|
||||||
idField: 'pk',
|
idField: 'pk',
|
||||||
treeShowField: 'part',
|
treeShowField: show_installed_items ? 'part' : null,
|
||||||
onPostBody: function() {
|
onLoadSuccess: function(data) {
|
||||||
|
let records = data.results || data;
|
||||||
|
|
||||||
|
// Set the 'parent' ID for each root item
|
||||||
|
if (!loaded && show_installed_items) {
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
records[i].belongs_to_item = parent_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded = true;
|
||||||
|
$(table).bootstrapTable('load', records);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPostBody: function() {
|
||||||
if (show_installed_items) {
|
if (show_installed_items) {
|
||||||
table.treegrid({
|
table.treegrid({
|
||||||
treeColumn: 1,
|
treeColumn: 1,
|
||||||
|
|
@ -2618,7 +2658,7 @@ function loadStockLocationTable(table, options) {
|
||||||
} else {
|
} else {
|
||||||
html += `
|
html += `
|
||||||
<a href='#' pk='${row.pk}' class='load-sub-location'>
|
<a href='#' pk='${row.pk}' class='load-sub-location'>
|
||||||
<span class='fas fa-sync-alt' title='{% trans "Load Subloactions" %}'></span>
|
<span class='fas fa-sync-alt' title='{% trans "Load Sublocations" %}'></span>
|
||||||
</a> `;
|
</a> `;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2809,7 +2849,7 @@ function loadStockTrackingTable(table, options) {
|
||||||
if (details.salesorder_detail) {
|
if (details.salesorder_detail) {
|
||||||
html += renderLink(
|
html += renderLink(
|
||||||
details.salesorder_detail.reference,
|
details.salesorder_detail.reference,
|
||||||
`/order/sales-order/${details.salesorder}`
|
`/order/sales-order/${details.salesorder}/`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
html += `<em>{% trans "Sales Order no longer exists" %}</em>`;
|
html += `<em>{% trans "Sales Order no longer exists" %}</em>`;
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,7 @@ def update_group_roles(group, debug=False):
|
||||||
|
|
||||||
# Iterate through each permission already assigned to this group,
|
# Iterate through each permission already assigned to this group,
|
||||||
# and create a simplified permission key string
|
# and create a simplified permission key string
|
||||||
for p in group.permissions.all():
|
for p in group.permissions.all().prefetch_related('content_type'):
|
||||||
(permission, app, model) = p.natural_key()
|
(permission, app, model) = p.natural_key()
|
||||||
|
|
||||||
permission_string = '{app}.{perm}'.format(
|
permission_string = '{app}.{perm}'.format(
|
||||||
|
|
|
||||||
2
Procfile
2
Procfile
|
|
@ -1,3 +1,3 @@
|
||||||
web: env/bin/gunicorn --chdir $APP_HOME/InvenTree -c InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT
|
web: env/bin/gunicorn --chdir $APP_HOME/InvenTree -c InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT
|
||||||
worker: env/bin/python InvenTree/manage.py qcluster
|
worker: env/bin/python InvenTree/manage.py qcluster
|
||||||
cli: . env/bin/activate && exec env/bin/python -m invoke
|
cli: echo "" && . env/bin/activate && exec env/bin/python -m invoke
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ INVENTREE_DB_PORT=5432
|
||||||
#INVENTREE_CACHE_PORT=6379
|
#INVENTREE_CACHE_PORT=6379
|
||||||
|
|
||||||
# Options for gunicorn server
|
# Options for gunicorn server
|
||||||
INVENTREE_GUNICORN_TIMEOUT=30
|
INVENTREE_GUNICORN_TIMEOUT=90
|
||||||
|
|
||||||
# Enable custom plugins?
|
# Enable custom plugins?
|
||||||
INVENTREE_PLUGINS_ENABLED=False
|
INVENTREE_PLUGINS_ENABLED=False
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Basic package requirements
|
# Basic package requirements
|
||||||
invoke>=1.4.0 # Invoke build tool
|
invoke>=1.4.0 # Invoke build tool
|
||||||
pyyaml>=6.0
|
pyyaml>=6.0.1
|
||||||
setuptools==65.6.3
|
setuptools==65.6.3
|
||||||
wheel>=0.37.0
|
wheel>=0.37.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ In addition to providing the ability for end-users to provide their own reportin
|
||||||
InvenTree report templates utilize the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine.
|
InvenTree report templates utilize the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine.
|
||||||
|
|
||||||
!!! info "WeasyPrint"
|
!!! info "WeasyPrint"
|
||||||
WeasyPrint is an extremely powerful and flexible reporting library. Refer to the [WeasyPrint docs](https://weasyprint.readthedocs.io/en/stable/) for further information.
|
WeasyPrint is an extremely powerful and flexible reporting library. Refer to the [WeasyPrint docs](https://doc.courtbouillon.org/weasyprint/stable/) for further information.
|
||||||
|
|
||||||
### Stylesheets
|
### Stylesheets
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ title: InvenTree Single Sign On
|
||||||
|
|
||||||
## Single Sign On
|
## Single Sign On
|
||||||
|
|
||||||
InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://django-allauth.readthedocs.io/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://django-allauth.readthedocs.io/en/latest/providers.html).
|
InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://django-allauth.readthedocs.io/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html).
|
||||||
|
|
||||||
!!! tip "Provider Documentation"
|
!!! tip "Provider Documentation"
|
||||||
There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/providers.html)
|
There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html)
|
||||||
|
|
||||||
## SSO Configuration
|
## SSO Configuration
|
||||||
|
|
||||||
|
|
@ -28,8 +28,8 @@ There are two variables in the configuration file which define the operation of
|
||||||
|
|
||||||
| Key | Description | More Info |
|
| Key | Description | More Info |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://django-allauth.readthedocs.io/en/latest/installation.html) |
|
| `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://django-allauth.readthedocs.io/en/latest/installation/quickstart.html) |
|
||||||
| `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://django-allauth.readthedocs.io/en/latest/providers.html) |
|
| `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) |
|
||||||
|
|
||||||
In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module:
|
In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module:
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ In the example below, SSO provider modules are activated for *google*, *github*
|
||||||
The next step is to create an external authentication app with your provider of choice. This step is wholly separate to your InvenTree installation, and must be performed before continuing further.
|
The next step is to create an external authentication app with your provider of choice. This step is wholly separate to your InvenTree installation, and must be performed before continuing further.
|
||||||
|
|
||||||
!!! info "Read the Documentation"
|
!!! info "Read the Documentation"
|
||||||
The [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/providers.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
|
The [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
|
||||||
|
|
||||||
In general, the external app will generate a *key* and *secret* pair - although different terminology may be used, depending on the provider.
|
In general, the external app will generate a *key* and *secret* pair - although different terminology may be used, depending on the provider.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,9 +155,16 @@ The following email settings are available:
|
||||||
| INVENTREE_EMAIL_PASSWORD | email.password | Email account password | *Not specified* |
|
| INVENTREE_EMAIL_PASSWORD | email.password | Email account password | *Not specified* |
|
||||||
| INVENTREE_EMAIL_TLS | email.tls | Enable TLS support | False |
|
| INVENTREE_EMAIL_TLS | email.tls | Enable TLS support | False |
|
||||||
| INVENTREE_EMAIL_SSL | email.ssl | Enable SSL support | False |
|
| INVENTREE_EMAIL_SSL | email.ssl | Enable SSL support | False |
|
||||||
| INVENTREE_EMAIL_SENDER | email.sender | Name of sender | *Not specified* |
|
| INVENTREE_EMAIL_SENDER | email.sender | Sending email address | *Not specified* |
|
||||||
| INVENTREE_EMAIL_PREFIX | email.prefix | Prefix for subject text | [InvenTree] |
|
| INVENTREE_EMAIL_PREFIX | email.prefix | Prefix for subject text | [InvenTree] |
|
||||||
|
|
||||||
|
### Sender Email
|
||||||
|
|
||||||
|
The "sender" email address is the address from which InvenTree emails are sent (by default) and must be specified for outgoing emails to function:
|
||||||
|
|
||||||
|
!!! info "Fallback"
|
||||||
|
If `INVENTREE_EMAIL_SENDER` is not provided, the system will fall back to `INVENTREE_EMAIL_USERNAME` (if the username is a valid email address)
|
||||||
|
|
||||||
## Supported Currencies
|
## Supported Currencies
|
||||||
|
|
||||||
The currencies supported by InvenTree must be specified in the [configuration file](#configuration-file).
|
The currencies supported by InvenTree must be specified in the [configuration file](#configuration-file).
|
||||||
|
|
@ -228,9 +235,9 @@ InvenTree provides allowance for additional sign-in options. The following optio
|
||||||
|
|
||||||
### Single Sign On
|
### Single Sign On
|
||||||
|
|
||||||
SSO backends for all required authentication providers need to be added to the config file as a list under the key `social_backends`. The correct backend-name can be found in django-allauths [configuration documentation](https://django-allauth.readthedocs.io/en/latest/installation.html#django).
|
SSO backends for all required authentication providers need to be added to the config file as a list under the key `social_backends`. The correct backend-name can be found in django-allauths [configuration documentation](https://django-allauth.readthedocs.io/en/latest/installation/quickstart.html).
|
||||||
|
|
||||||
If the selected providers need additional settings they must be added as dicts under the key `social_providers`. The correct settings can be found in the django-allauths [provider documentation](https://django-allauth.readthedocs.io/en/latest/providers.html).
|
If the selected providers need additional settings they must be added as dicts under the key `social_providers`. The correct settings can be found in the django-allauths [provider documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html).
|
||||||
|
|
||||||
!!! warning "You are not done"
|
!!! warning "You are not done"
|
||||||
SSO still needs credentials for all providers and has to be enabled in the [global settings](../settings/global.md)!
|
SSO still needs credentials for all providers and has to be enabled in the [global settings](../settings/global.md)!
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ sudo apt-get install \
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! warning "Weasyprint"
|
!!! warning "Weasyprint"
|
||||||
On some systems, the dependencies for the `weasyprint` package might not be installed. Consider running through the [weasyprint installation steps](https://weasyprint.readthedocs.io/en/stable/install.html) before moving forward.
|
On some systems, the dependencies for the `weasyprint` package might not be installed. Consider running through the [weasyprint installation steps](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation) before moving forward.
|
||||||
|
|
||||||
|
|
||||||
### Create InvenTree User
|
### Create InvenTree User
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ mkdocs:
|
||||||
configuration: docs/mkdocs.yml
|
configuration: docs/mkdocs.yml
|
||||||
|
|
||||||
python:
|
python:
|
||||||
version: 3.7
|
|
||||||
install:
|
install:
|
||||||
- requirements: docs/requirements.txt
|
- requirements: docs/requirements.txt
|
||||||
|
|
||||||
|
build:
|
||||||
|
os: "ubuntu-22.04"
|
||||||
|
tools:
|
||||||
|
python: "3.9"
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ pytz==2023.3
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# django
|
# django
|
||||||
pyyaml==6.0
|
pyyaml==6.0.1
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# pre-commit
|
# pre-commit
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ pillow # Image manipulation
|
||||||
pint==0.21 # Unit conversion # FIXED 2023-05-30 breaks tests https://github.com/matmair/InvenTree/actions/runs/5095665936/jobs/9160852560
|
pint==0.21 # Unit conversion # FIXED 2023-05-30 breaks tests https://github.com/matmair/InvenTree/actions/runs/5095665936/jobs/9160852560
|
||||||
python-barcode[images] # Barcode generator
|
python-barcode[images] # Barcode generator
|
||||||
python-dotenv # Environment variable management
|
python-dotenv # Environment variable management
|
||||||
|
pyyaml>=6.0.1 # YAML parsing
|
||||||
qrcode[pil] # QR code generator
|
qrcode[pil] # QR code generator
|
||||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||||
regex # Advanced regular expressions
|
regex # Advanced regular expressions
|
||||||
|
|
|
||||||
|
|
@ -239,8 +239,9 @@ pytz==2023.3
|
||||||
# django-dbbackup
|
# django-dbbackup
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
# icalendar
|
# icalendar
|
||||||
pyyaml==6.0
|
pyyaml==6.0.1
|
||||||
# via
|
# via
|
||||||
|
# -r requirements.in
|
||||||
# drf-spectacular
|
# drf-spectacular
|
||||||
# tablib
|
# tablib
|
||||||
qrcode[pil]==7.4.2
|
qrcode[pil]==7.4.2
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue