mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into plugin-2037
This commit is contained in:
commit
32122102e6
@ -34,18 +34,47 @@ class InvenTreeOrderingFilter(OrderingFilter):
|
|||||||
Ordering fields should be mapped to separate fields
|
Ordering fields should be mapped to separate fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for idx, field in enumerate(ordering):
|
ordering_initial = ordering
|
||||||
|
ordering = []
|
||||||
|
|
||||||
reverse = False
|
for field in ordering_initial:
|
||||||
|
|
||||||
if field.startswith('-'):
|
reverse = field.startswith('-')
|
||||||
field = field[1:]
|
|
||||||
reverse = True
|
|
||||||
|
|
||||||
if field in aliases:
|
|
||||||
ordering[idx] = aliases[field]
|
|
||||||
|
|
||||||
if reverse:
|
if reverse:
|
||||||
ordering[idx] = '-' + ordering[idx]
|
field = field[1:]
|
||||||
|
|
||||||
|
# Are aliases defined for this field?
|
||||||
|
if field in aliases:
|
||||||
|
alias = aliases[field]
|
||||||
|
else:
|
||||||
|
alias = field
|
||||||
|
|
||||||
|
"""
|
||||||
|
Potentially, a single field could be "aliased" to multiple field,
|
||||||
|
|
||||||
|
(For example to enforce a particular ordering sequence)
|
||||||
|
|
||||||
|
e.g. to filter first by the integer value...
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
"reference": ["integer_ref", "reference"]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(alias) is str:
|
||||||
|
alias = [alias]
|
||||||
|
elif type(alias) in [list, tuple]:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Unsupported alias type
|
||||||
|
continue
|
||||||
|
|
||||||
|
for a in alias:
|
||||||
|
if reverse:
|
||||||
|
a = '-' + a
|
||||||
|
|
||||||
|
ordering.append(a)
|
||||||
|
|
||||||
return ordering
|
return ordering
|
||||||
|
@ -4,10 +4,12 @@ Helper forms which subclass Django forms to provide additional functionality
|
|||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User, Group
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field
|
from crispy_forms.layout import Layout, Field
|
||||||
@ -20,6 +22,8 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
|||||||
from part.models import PartCategory
|
from part.models import PartCategory
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class HelperForm(forms.ModelForm):
|
class HelperForm(forms.ModelForm):
|
||||||
""" Provides simple integration of crispy_forms extension. """
|
""" Provides simple integration of crispy_forms extension. """
|
||||||
@ -223,11 +227,11 @@ class CustomSignupForm(SignupForm):
|
|||||||
# check for two mail fields
|
# check for two mail fields
|
||||||
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
|
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
|
||||||
self.fields["email2"] = forms.EmailField(
|
self.fields["email2"] = forms.EmailField(
|
||||||
label=_("E-mail (again)"),
|
label=_("Email (again)"),
|
||||||
widget=forms.TextInput(
|
widget=forms.TextInput(
|
||||||
attrs={
|
attrs={
|
||||||
"type": "email",
|
"type": "email",
|
||||||
"placeholder": _("E-mail address confirmation"),
|
"placeholder": _("Email address confirmation"),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -257,10 +261,22 @@ class RegistratonMixin:
|
|||||||
Mixin to check if registration should be enabled
|
Mixin to check if registration should be enabled
|
||||||
"""
|
"""
|
||||||
def is_open_for_signup(self, request):
|
def is_open_for_signup(self, request):
|
||||||
if InvenTreeSetting.get_setting('EMAIL_HOST', None) and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True):
|
if settings.EMAIL_HOST and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True):
|
||||||
return super().is_open_for_signup(request)
|
return super().is_open_for_signup(request)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def save_user(self, request, user, form, commit=True):
|
||||||
|
user = super().save_user(request, user, form, commit=commit)
|
||||||
|
start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP')
|
||||||
|
if start_group:
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(id=start_group)
|
||||||
|
user.groups.add(group)
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
logger.error('The setting `SIGNUP_GROUP` contains an non existant group', start_group)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter):
|
class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter):
|
||||||
"""
|
"""
|
||||||
@ -268,7 +284,7 @@ class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter):
|
|||||||
"""
|
"""
|
||||||
def send_mail(self, template_prefix, email, context):
|
def send_mail(self, template_prefix, email, context):
|
||||||
"""only send mail if backend configured"""
|
"""only send mail if backend configured"""
|
||||||
if InvenTreeSetting.get_setting('EMAIL_HOST', None):
|
if settings.EMAIL_HOST:
|
||||||
return super().send_mail(template_prefix, email, context)
|
return super().send_mail(template_prefix, email, context)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -1 +0,0 @@
|
|||||||
{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1}
|
|
@ -4,6 +4,7 @@ Generic models which provide extra functionality over base Django model types.
|
|||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -43,6 +44,48 @@ def rename_attachment(instance, filename):
|
|||||||
return os.path.join(instance.getSubdir(), filename)
|
return os.path.join(instance.getSubdir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceIndexingMixin(models.Model):
|
||||||
|
"""
|
||||||
|
A mixin for keeping track of numerical copies of the "reference" field.
|
||||||
|
|
||||||
|
Here, we attempt to convert a "reference" field value (char) to an integer,
|
||||||
|
for performing fast natural sorting.
|
||||||
|
|
||||||
|
This requires extra database space (due to the extra table column),
|
||||||
|
but is required as not all supported database backends provide equivalent casting.
|
||||||
|
|
||||||
|
This mixin adds a field named 'reference_int'.
|
||||||
|
|
||||||
|
- If the 'reference' field can be cast to an integer, it is stored here
|
||||||
|
- If the 'reference' field *starts* with an integer, it is stored here
|
||||||
|
- Otherwise, we store zero
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def rebuild_reference_field(self):
|
||||||
|
|
||||||
|
reference = getattr(self, 'reference', '')
|
||||||
|
|
||||||
|
# Default value if we cannot convert to an integer
|
||||||
|
ref_int = 0
|
||||||
|
|
||||||
|
# Look at the start of the string - can it be "integerized"?
|
||||||
|
result = re.match(r"^(\d+)", reference)
|
||||||
|
|
||||||
|
if result and len(result.groups()) == 1:
|
||||||
|
ref = result.groups()[0]
|
||||||
|
try:
|
||||||
|
ref_int = int(ref)
|
||||||
|
except:
|
||||||
|
ref_int = 0
|
||||||
|
|
||||||
|
self.reference_int = ref_int
|
||||||
|
|
||||||
|
reference_int = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachment(models.Model):
|
class InvenTreeAttachment(models.Model):
|
||||||
""" Provides an abstracted class for managing file attachments.
|
""" Provides an abstracted class for managing file attachments.
|
||||||
|
|
||||||
|
@ -396,39 +396,6 @@ Q_CLUSTER = {
|
|||||||
'sync': False,
|
'sync': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Markdownx configuration
|
|
||||||
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
|
||||||
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
|
|
||||||
|
|
||||||
# Markdownify configuration
|
|
||||||
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
|
|
||||||
|
|
||||||
MARKDOWNIFY_WHITELIST_TAGS = [
|
|
||||||
'a',
|
|
||||||
'abbr',
|
|
||||||
'b',
|
|
||||||
'blockquote',
|
|
||||||
'em',
|
|
||||||
'h1', 'h2', 'h3',
|
|
||||||
'i',
|
|
||||||
'img',
|
|
||||||
'li',
|
|
||||||
'ol',
|
|
||||||
'p',
|
|
||||||
'strong',
|
|
||||||
'ul'
|
|
||||||
]
|
|
||||||
|
|
||||||
MARKDOWNIFY_WHITELIST_ATTRS = [
|
|
||||||
'href',
|
|
||||||
'src',
|
|
||||||
'alt',
|
|
||||||
]
|
|
||||||
|
|
||||||
MARKDOWNIFY_BLEACH = False
|
|
||||||
|
|
||||||
DATABASES = {}
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Configure the database backend based on the user-specified values.
|
Configure the database backend based on the user-specified values.
|
||||||
|
|
||||||
@ -495,7 +462,47 @@ logger.info(f"DB_ENGINE: {db_engine}")
|
|||||||
logger.info(f"DB_NAME: {db_name}")
|
logger.info(f"DB_NAME: {db_name}")
|
||||||
logger.info(f"DB_HOST: {db_host}")
|
logger.info(f"DB_HOST: {db_host}")
|
||||||
|
|
||||||
DATABASES['default'] = db_config
|
"""
|
||||||
|
In addition to base-level database configuration, we may wish to specify specific options to the database backend
|
||||||
|
Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 'OPTIONS' or 'options' can be specified in config.yaml
|
||||||
|
db_options = db_config.get('OPTIONS', db_config.get('options', {}))
|
||||||
|
|
||||||
|
# Specific options for postgres backend
|
||||||
|
if 'postgres' in db_engine:
|
||||||
|
from psycopg2.extensions import ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_SERIALIZABLE
|
||||||
|
|
||||||
|
# Connection timeout
|
||||||
|
if 'connect_timeout' not in db_options:
|
||||||
|
db_options['connect_timeout'] = int(os.getenv('INVENTREE_DB_TIMEOUT', 2))
|
||||||
|
|
||||||
|
# Postgres's default isolation level is Read Committed which is
|
||||||
|
# normally fine, but most developers think the database server is
|
||||||
|
# actually going to do Serializable type checks on the queries to
|
||||||
|
# protect against simultaneous changes.
|
||||||
|
if 'isolation_level' not in db_options:
|
||||||
|
serializable = _is_true(os.getenv("PG_ISOLATION_SERIALIZABLE", "true"))
|
||||||
|
db_options['isolation_level'] = ISOLATION_LEVEL_SERIALIZABLE if serializable else ISOLATION_LEVEL_READ_COMMITTED
|
||||||
|
|
||||||
|
# Specific options for MySql / MariaDB backend
|
||||||
|
if 'mysql' in db_engine:
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Specific options for sqlite backend
|
||||||
|
if 'sqlite' in db_engine:
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Provide OPTIONS dict back to the database configuration dict
|
||||||
|
db_config['OPTIONS'] = db_options
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': db_config
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
'default': {
|
'default': {
|
||||||
@ -695,6 +702,37 @@ ACCOUNT_FORMS = {
|
|||||||
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
||||||
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
|
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
|
||||||
|
|
||||||
|
# Markdownx configuration
|
||||||
|
# Ref: https://neutronx.github.io/django-markdownx/customization/
|
||||||
|
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
|
||||||
|
|
||||||
|
# Markdownify configuration
|
||||||
|
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
|
||||||
|
|
||||||
|
MARKDOWNIFY_WHITELIST_TAGS = [
|
||||||
|
'a',
|
||||||
|
'abbr',
|
||||||
|
'b',
|
||||||
|
'blockquote',
|
||||||
|
'em',
|
||||||
|
'h1', 'h2', 'h3',
|
||||||
|
'i',
|
||||||
|
'img',
|
||||||
|
'li',
|
||||||
|
'ol',
|
||||||
|
'p',
|
||||||
|
'strong',
|
||||||
|
'ul'
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKDOWNIFY_WHITELIST_ATTRS = [
|
||||||
|
'href',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKDOWNIFY_BLEACH = False
|
||||||
|
|
||||||
# Plugins
|
# Plugins
|
||||||
PLUGIN_URL = 'plugin'
|
PLUGIN_URL = 'plugin'
|
||||||
|
|
||||||
|
@ -455,6 +455,10 @@
|
|||||||
-webkit-opacity: 10%;
|
-webkit-opacity: 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-condensed {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
/* grid display for part images */
|
/* grid display for part images */
|
||||||
|
|
||||||
.table-img-grid tr {
|
.table-img-grid tr {
|
||||||
|
@ -5,6 +5,7 @@ Custom field validators for InvenTree
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
|
|
||||||
@ -156,3 +157,33 @@ def validate_overage(value):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Overage must be an integer value or a percentage")
|
_("Overage must be an integer value or a percentage")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_part_name_format(self):
|
||||||
|
"""
|
||||||
|
Validate part name format.
|
||||||
|
Make sure that each template container has a field of Part Model
|
||||||
|
"""
|
||||||
|
|
||||||
|
jinja_template_regex = re.compile('{{.*?}}')
|
||||||
|
field_name_regex = re.compile('(?<=part\\.)[A-z]+')
|
||||||
|
for jinja_template in jinja_template_regex.findall(str(self)):
|
||||||
|
# make sure at least one and only one field is present inside the parser
|
||||||
|
field_names = field_name_regex.findall(jinja_template)
|
||||||
|
if len(field_names) < 1:
|
||||||
|
raise ValidationError({
|
||||||
|
'value': 'At least one field must be present inside a jinja template container i.e {{}}'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Make sure that the field_name exists in Part model
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
|
for field_name in field_names:
|
||||||
|
try:
|
||||||
|
Part._meta.get_field(field_name)
|
||||||
|
except FieldDoesNotExist:
|
||||||
|
raise ValidationError({
|
||||||
|
'value': f'{field_name} does not exist in Part Model'
|
||||||
|
})
|
||||||
|
|
||||||
|
return True
|
||||||
|
@ -9,6 +9,10 @@ from .models import Build, BuildItem
|
|||||||
|
|
||||||
class BuildAdmin(ImportExportModelAdmin):
|
class BuildAdmin(ImportExportModelAdmin):
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
'reference_int',
|
||||||
|
]
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
'reference',
|
'reference',
|
||||||
'title',
|
'title',
|
||||||
|
@ -17,11 +17,12 @@ from django_filters import rest_framework as rest_filters
|
|||||||
|
|
||||||
from InvenTree.api import AttachmentMixin
|
from InvenTree.api import AttachmentMixin
|
||||||
from InvenTree.helpers import str2bool, isNull
|
from InvenTree.helpers import str2bool, isNull
|
||||||
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
from .models import Build, BuildItem, BuildOrderAttachment
|
from .models import Build, BuildItem, BuildOrderAttachment
|
||||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||||
from .serializers import BuildAllocationSerializer
|
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildFilter(rest_filters.FilterSet):
|
class BuildFilter(rest_filters.FilterSet):
|
||||||
@ -68,7 +69,7 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter,
|
InvenTreeOrderingFilter,
|
||||||
]
|
]
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
@ -83,6 +84,10 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
'responsible',
|
'responsible',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
'reference': ['reference_int', 'reference'],
|
||||||
|
}
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'reference',
|
'reference',
|
||||||
'part__name',
|
'part__name',
|
||||||
@ -184,6 +189,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
|||||||
serializer_class = BuildSerializer
|
serializer_class = BuildSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BuildUnallocate(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for unallocating stock items from a build order
|
||||||
|
|
||||||
|
- The BuildOrder object is specified by the URL
|
||||||
|
- "output" (StockItem) can optionally be specified
|
||||||
|
- "bom_item" can optionally be specified
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
|
serializer_class = BuildUnallocationSerializer
|
||||||
|
|
||||||
|
def get_build(self):
|
||||||
|
"""
|
||||||
|
Returns the BuildOrder associated with this API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
pk = self.kwargs.get('pk', None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
build = Build.objects.get(pk=pk)
|
||||||
|
except (ValueError, Build.DoesNotExist):
|
||||||
|
raise ValidationError(_("Matching build order does not exist"))
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['build'] = self.get_build()
|
||||||
|
ctx['request'] = self.request
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class BuildAllocate(generics.CreateAPIView):
|
class BuildAllocate(generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to allocate stock items to a build order
|
API endpoint to allocate stock items to a build order
|
||||||
@ -349,6 +390,7 @@ build_api_urls = [
|
|||||||
# Build Detail
|
# Build Detail
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||||
|
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -137,32 +137,6 @@ class BuildOutputDeleteForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class UnallocateBuildForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for auto-de-allocation of stock from a build
|
|
||||||
"""
|
|
||||||
|
|
||||||
confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock'))
|
|
||||||
|
|
||||||
output_id = forms.IntegerField(
|
|
||||||
required=False,
|
|
||||||
widget=forms.HiddenInput()
|
|
||||||
)
|
|
||||||
|
|
||||||
part_id = forms.IntegerField(
|
|
||||||
required=False,
|
|
||||||
widget=forms.HiddenInput(),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Build
|
|
||||||
fields = [
|
|
||||||
'confirm',
|
|
||||||
'output_id',
|
|
||||||
'part_id',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CompleteBuildForm(HelperForm):
|
class CompleteBuildForm(HelperForm):
|
||||||
"""
|
"""
|
||||||
Form for marking a build as complete
|
Form for marking a build as complete
|
||||||
|
18
InvenTree/build/migrations/0031_build_reference_int.py
Normal file
18
InvenTree/build/migrations/0031_build_reference_int.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-10-14 06:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('build', '0030_alter_build_reference'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='build',
|
||||||
|
name='reference_int',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
50
InvenTree/build/migrations/0032_auto_20211014_0632.py
Normal file
50
InvenTree/build/migrations/0032_auto_20211014_0632.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-10-14 06:32
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def build_refs(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Rebuild the integer "reference fields" for existing Build objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
BuildOrder = apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
for build in BuildOrder.objects.all():
|
||||||
|
|
||||||
|
ref = 0
|
||||||
|
|
||||||
|
result = re.match(r"^(\d+)", build.reference)
|
||||||
|
|
||||||
|
if result and len(result.groups()) == 1:
|
||||||
|
try:
|
||||||
|
ref = int(result.groups()[0])
|
||||||
|
except:
|
||||||
|
ref = 0
|
||||||
|
|
||||||
|
build.reference_int = ref
|
||||||
|
build.save()
|
||||||
|
|
||||||
|
def unbuild_refs(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Provided only for reverse migration compatibility
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('build', '0031_build_reference_int'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
build_refs,
|
||||||
|
reverse_code=unbuild_refs
|
||||||
|
)
|
||||||
|
]
|
@ -28,7 +28,7 @@ from mptt.exceptions import InvalidMove
|
|||||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||||
from InvenTree.validators import validate_build_order_reference
|
from InvenTree.validators import validate_build_order_reference
|
||||||
from InvenTree.models import InvenTreeAttachment
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ def get_next_build_number():
|
|||||||
return reference
|
return reference
|
||||||
|
|
||||||
|
|
||||||
class Build(MPTTModel):
|
class Build(MPTTModel, ReferenceIndexingMixin):
|
||||||
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
@ -108,6 +108,8 @@ class Build(MPTTModel):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.rebuild_reference_field()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
except InvalidMove:
|
except InvalidMove:
|
||||||
@ -587,9 +589,13 @@ class Build(MPTTModel):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def unallocateOutput(self, output, part=None):
|
def unallocateStock(self, bom_item=None, output=None):
|
||||||
"""
|
"""
|
||||||
Unallocate all stock which are allocated against the provided "output" (StockItem)
|
Unallocate stock from this Build
|
||||||
|
|
||||||
|
arguments:
|
||||||
|
- bom_item: Specify a particular BomItem to unallocate stock against
|
||||||
|
- output: Specify a particular StockItem (output) to unallocate stock against
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocations = BuildItem.objects.filter(
|
allocations = BuildItem.objects.filter(
|
||||||
@ -597,34 +603,8 @@ class Build(MPTTModel):
|
|||||||
install_into=output
|
install_into=output
|
||||||
)
|
)
|
||||||
|
|
||||||
if part:
|
if bom_item:
|
||||||
allocations = allocations.filter(stock_item__part=part)
|
allocations = allocations.filter(bom_item=bom_item)
|
||||||
|
|
||||||
allocations.delete()
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def unallocateUntracked(self, part=None):
|
|
||||||
"""
|
|
||||||
Unallocate all "untracked" stock
|
|
||||||
"""
|
|
||||||
|
|
||||||
allocations = BuildItem.objects.filter(
|
|
||||||
build=self,
|
|
||||||
install_into=None
|
|
||||||
)
|
|
||||||
|
|
||||||
if part:
|
|
||||||
allocations = allocations.filter(stock_item__part=part)
|
|
||||||
|
|
||||||
allocations.delete()
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def unallocateAll(self):
|
|
||||||
"""
|
|
||||||
Deletes all stock allocations for this build.
|
|
||||||
"""
|
|
||||||
|
|
||||||
allocations = BuildItem.objects.filter(build=self)
|
|
||||||
|
|
||||||
allocations.delete()
|
allocations.delete()
|
||||||
|
|
||||||
@ -720,7 +700,7 @@ class Build(MPTTModel):
|
|||||||
raise ValidationError(_("Build output does not match Build Order"))
|
raise ValidationError(_("Build output does not match Build Order"))
|
||||||
|
|
||||||
# Unallocate all build items against the output
|
# Unallocate all build items against the output
|
||||||
self.unallocateOutput(output)
|
self.unallocateStock(output=output)
|
||||||
|
|
||||||
# Remove the build output from the database
|
# Remove the build output from the database
|
||||||
output.delete()
|
output.delete()
|
||||||
@ -1153,16 +1133,12 @@ class BuildItem(models.Model):
|
|||||||
i) The sub_part points to the same part as the referenced StockItem
|
i) The sub_part points to the same part as the referenced StockItem
|
||||||
ii) The BomItem allows variants and the part referenced by the StockItem
|
ii) The BomItem allows variants and the part referenced by the StockItem
|
||||||
is a variant of the sub_part referenced by the BomItem
|
is a variant of the sub_part referenced by the BomItem
|
||||||
|
iii) The Part referenced by the StockItem is a valid substitute for the BomItem
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.build and self.build.part == self.bom_item.part:
|
if self.build and self.build.part == self.bom_item.part:
|
||||||
|
|
||||||
# Check that the sub_part points to the stock_item (either directly or via a variant)
|
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
|
||||||
if self.bom_item.sub_part == self.stock_item.part:
|
|
||||||
bom_item_valid = True
|
|
||||||
|
|
||||||
elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False):
|
|
||||||
bom_item_valid = True
|
|
||||||
|
|
||||||
# If the existing BomItem is *not* valid, try to find a match
|
# If the existing BomItem is *not* valid, try to find a match
|
||||||
if not bom_item_valid:
|
if not bom_item_valid:
|
||||||
|
@ -120,6 +120,61 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildUnallocationSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
DRF serializer for unallocating stock from a BuildOrder
|
||||||
|
|
||||||
|
Allocated stock can be unallocated with a number of filters:
|
||||||
|
|
||||||
|
- output: Filter against a particular build output (blank = untracked stock)
|
||||||
|
- bom_item: Filter against a particular BOM line item
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
bom_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=BomItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
label=_('BOM Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
output = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockItem.objects.filter(
|
||||||
|
is_building=True,
|
||||||
|
),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
label=_("Build output"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_output(self, stock_item):
|
||||||
|
|
||||||
|
# Stock item must point to the same build order!
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
if stock_item and stock_item.build != build:
|
||||||
|
raise ValidationError(_("Build output must point to the same build"))
|
||||||
|
|
||||||
|
return stock_item
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
'Save' the serializer data.
|
||||||
|
This performs the actual unallocation against the build order
|
||||||
|
"""
|
||||||
|
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
build.unallocateStock(
|
||||||
|
bom_item=data['bom_item'],
|
||||||
|
output=data['output']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BuildAllocationItemSerializer(serializers.Serializer):
|
class BuildAllocationItemSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
A serializer for allocating a single stock item against a build order
|
A serializer for allocating a single stock item against a build order
|
||||||
|
@ -197,7 +197,7 @@
|
|||||||
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
||||||
<span class='fas fa-sign-in-alt'></span>
|
<span class='fas fa-sign-in-alt'></span>
|
||||||
</button>
|
</button>
|
||||||
<div class='filter-list' id='filter-list-build-items'>
|
<div class='filter-list' id='filter-list-builditems'>
|
||||||
<!-- Empty div for table filters-->
|
<!-- Empty div for table filters-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#btn-unallocate').on('click', function() {
|
$('#btn-unallocate').on('click', function() {
|
||||||
launchModalForm(
|
unallocateStock({{ build.id }}, {
|
||||||
"{% url 'build-unallocate' build.id %}",
|
table: '#allocation-table-untracked',
|
||||||
{
|
});
|
||||||
success: reloadTable,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#allocate-selected-items').click(function() {
|
$('#allocate-selected-items').click(function() {
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
{% extends "modal_form.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% load inventree_extras %}
|
|
||||||
{% block pre_form_content %}
|
|
||||||
|
|
||||||
{{ block.super }}
|
|
||||||
|
|
||||||
|
|
||||||
<div class='alert alert-block alert-danger'>
|
|
||||||
{% trans "Are you sure you wish to unallocate all stock for this build?" %}
|
|
||||||
<br>
|
|
||||||
{% trans "All incomplete stock allocations will be removed from the build" %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -118,6 +118,26 @@ class BuildTest(TestCase):
|
|||||||
|
|
||||||
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
|
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
|
||||||
|
|
||||||
|
def test_ref_int(self):
|
||||||
|
"""
|
||||||
|
Test the "integer reference" field used for natural sorting
|
||||||
|
"""
|
||||||
|
|
||||||
|
for ii in range(10):
|
||||||
|
build = Build(
|
||||||
|
reference=f"{ii}_abcde",
|
||||||
|
quantity=1,
|
||||||
|
part=self.assembly,
|
||||||
|
title="Making some parts"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(build.reference_int, 0)
|
||||||
|
|
||||||
|
build.save()
|
||||||
|
|
||||||
|
# After saving, the integer reference should have been updated
|
||||||
|
self.assertEqual(build.reference_int, ii)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@ -250,7 +270,7 @@ class BuildTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(unallocated), 1)
|
self.assertEqual(len(unallocated), 1)
|
||||||
|
|
||||||
self.build.unallocateUntracked()
|
self.build.unallocateStock()
|
||||||
|
|
||||||
unallocated = self.build.unallocatedParts(None)
|
unallocated = self.build.unallocatedParts(None)
|
||||||
|
|
||||||
|
@ -323,22 +323,3 @@ class TestBuildViews(TestCase):
|
|||||||
|
|
||||||
b = Build.objects.get(pk=1)
|
b = Build.objects.get(pk=1)
|
||||||
self.assertEqual(b.status, 30) # Build status is now CANCELLED
|
self.assertEqual(b.status, 30) # Build status is now CANCELLED
|
||||||
|
|
||||||
def test_build_unallocate(self):
|
|
||||||
""" Test the build unallocation view (ajax form) """
|
|
||||||
|
|
||||||
url = reverse('build-unallocate', args=(1,))
|
|
||||||
|
|
||||||
# Test without confirmation
|
|
||||||
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
data = json.loads(response.content)
|
|
||||||
self.assertFalse(data['form_valid'])
|
|
||||||
|
|
||||||
# Test with confirmation
|
|
||||||
response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
data = json.loads(response.content)
|
|
||||||
self.assertTrue(data['form_valid'])
|
|
||||||
|
@ -12,7 +12,6 @@ build_detail_urls = [
|
|||||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||||
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
||||||
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
|
||||||
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||||
|
|
||||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||||
|
@ -10,14 +10,13 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
from django.forms import HiddenInput
|
from django.forms import HiddenInput
|
||||||
|
|
||||||
from part.models import Part
|
|
||||||
from .models import Build
|
from .models import Build
|
||||||
from . import forms
|
from . import forms
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
|
|
||||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.views import InvenTreeRoleMixin
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
|
from InvenTree.helpers import str2bool, extract_serial_numbers
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||||
|
|
||||||
|
|
||||||
@ -246,88 +245,6 @@ class BuildOutputDelete(AjaxUpdateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BuildUnallocate(AjaxUpdateView):
|
|
||||||
""" View to un-allocate all parts from a build.
|
|
||||||
|
|
||||||
Provides a simple confirmation dialog with a BooleanField checkbox.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
form_class = forms.UnallocateBuildForm
|
|
||||||
ajax_form_title = _("Unallocate Stock")
|
|
||||||
ajax_template_name = "build/unallocate.html"
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
# Pointing to a particular build output?
|
|
||||||
output = self.get_param('output')
|
|
||||||
|
|
||||||
if output:
|
|
||||||
initials['output_id'] = output
|
|
||||||
|
|
||||||
# Pointing to a particular part?
|
|
||||||
part = self.get_param('part')
|
|
||||||
|
|
||||||
if part:
|
|
||||||
initials['part_id'] = part
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
build = self.get_object()
|
|
||||||
form = self.get_form()
|
|
||||||
|
|
||||||
confirm = request.POST.get('confirm', False)
|
|
||||||
|
|
||||||
output_id = request.POST.get('output_id', None)
|
|
||||||
|
|
||||||
if output_id:
|
|
||||||
|
|
||||||
# If a "null" output is provided, we are trying to unallocate "untracked" stock
|
|
||||||
if isNull(output_id):
|
|
||||||
output = None
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
output = StockItem.objects.get(pk=output_id)
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
output = None
|
|
||||||
|
|
||||||
part_id = request.POST.get('part_id', None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=part_id)
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
part = None
|
|
||||||
|
|
||||||
valid = False
|
|
||||||
|
|
||||||
if confirm is False:
|
|
||||||
form.add_error('confirm', _('Confirm unallocation of build stock'))
|
|
||||||
form.add_error(None, _('Check the confirmation box'))
|
|
||||||
else:
|
|
||||||
|
|
||||||
valid = True
|
|
||||||
|
|
||||||
# Unallocate the entire build
|
|
||||||
if not output_id:
|
|
||||||
build.unallocateAll()
|
|
||||||
# Unallocate a single output
|
|
||||||
elif output:
|
|
||||||
build.unallocateOutput(output, part=part)
|
|
||||||
# Unallocate "untracked" parts
|
|
||||||
else:
|
|
||||||
build.unallocateUntracked(part=part)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': valid,
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data)
|
|
||||||
|
|
||||||
|
|
||||||
class BuildComplete(AjaxUpdateView):
|
class BuildComplete(AjaxUpdateView):
|
||||||
"""
|
"""
|
||||||
View to mark the build as complete.
|
View to mark the build as complete.
|
||||||
|
@ -12,7 +12,7 @@ import math
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User, Group
|
||||||
from django.db.utils import IntegrityError, OperationalError
|
from django.db.utils import IntegrityError, OperationalError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@ -26,6 +26,7 @@ from django.core.exceptions import ValidationError
|
|||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
|
import InvenTree.validators
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -182,12 +183,9 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
else:
|
else:
|
||||||
choices = None
|
choices = None
|
||||||
|
|
||||||
"""
|
if callable(choices):
|
||||||
TODO:
|
|
||||||
if type(choices) is function:
|
|
||||||
# Evaluate the function (we expect it will return a list of tuples...)
|
# Evaluate the function (we expect it will return a list of tuples...)
|
||||||
return choices()
|
return choices()
|
||||||
"""
|
|
||||||
|
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
@ -479,6 +477,11 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def settings_group_options():
|
||||||
|
"""build up group tuple for settings based on gour choices"""
|
||||||
|
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeSetting(BaseInvenTreeSetting):
|
class InvenTreeSetting(BaseInvenTreeSetting):
|
||||||
"""
|
"""
|
||||||
An InvenTreeSetting object is a key:value pair used for storing
|
An InvenTreeSetting object is a key:value pair used for storing
|
||||||
@ -703,6 +706,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool
|
'validator': bool
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'PART_NAME_FORMAT': {
|
||||||
|
'name': _('Part Name Display Format'),
|
||||||
|
'description': _('Format to display the part name'),
|
||||||
|
'default': "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}{{ ' | ' if part.revision }}"
|
||||||
|
"{{ part.revision if part.revision }}",
|
||||||
|
'validator': InvenTree.validators.validate_part_name_format
|
||||||
|
},
|
||||||
|
|
||||||
'REPORT_DEBUG_MODE': {
|
'REPORT_DEBUG_MODE': {
|
||||||
'name': _('Debug Mode'),
|
'name': _('Debug Mode'),
|
||||||
'description': _('Generate reports in debug mode (HTML output)'),
|
'description': _('Generate reports in debug mode (HTML output)'),
|
||||||
@ -794,43 +805,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'default': 'PO',
|
'default': 'PO',
|
||||||
},
|
},
|
||||||
|
|
||||||
# enable/diable ui elements
|
|
||||||
'BUILD_FUNCTION_ENABLE': {
|
|
||||||
'name': _('Enable build'),
|
|
||||||
'description': _('Enable build functionality in InvenTree interface'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'BUY_FUNCTION_ENABLE': {
|
|
||||||
'name': _('Enable buy'),
|
|
||||||
'description': _('Enable buy functionality in InvenTree interface'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'SELL_FUNCTION_ENABLE': {
|
|
||||||
'name': _('Enable sell'),
|
|
||||||
'description': _('Enable sell functionality in InvenTree interface'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'STOCK_FUNCTION_ENABLE': {
|
|
||||||
'name': _('Enable stock'),
|
|
||||||
'description': _('Enable stock functionality in InvenTree interface'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'SO_FUNCTION_ENABLE': {
|
|
||||||
'name': _('Enable SO'),
|
|
||||||
'description': _('Enable SO functionality in InvenTree interface'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'PO_FUNCTION_ENABLE': {
|
|
||||||
'name': _('Enable PO'),
|
|
||||||
'description': _('Enable PO functionality in InvenTree interface'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
# login / SSO
|
# login / SSO
|
||||||
'LOGIN_ENABLE_PWD_FORGOT': {
|
'LOGIN_ENABLE_PWD_FORGOT': {
|
||||||
'name': _('Enable password forgot'),
|
'name': _('Enable password forgot'),
|
||||||
@ -851,7 +825,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
'LOGIN_MAIL_REQUIRED': {
|
'LOGIN_MAIL_REQUIRED': {
|
||||||
'name': _('E-Mail required'),
|
'name': _('Email required'),
|
||||||
'description': _('Require user to supply mail on signup'),
|
'description': _('Require user to supply mail on signup'),
|
||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
@ -874,7 +848,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'default': True,
|
'default': True,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
**settings.INTEGRATION_PLUGIN_SETTINGS
|
'SIGNUP_GROUP': {
|
||||||
|
'name': _('Group on signup'),
|
||||||
|
'description': _('Group new user are asigned on registration'),
|
||||||
|
'default': '',
|
||||||
|
'choices': settings_group_options
|
||||||
|
},
|
||||||
|
|
||||||
|
**settings.INTEGRATION_PLUGIN_SETTINGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -136,3 +136,24 @@ class SettingsViewTest(TestCase):
|
|||||||
for value in [False, 'False']:
|
for value in [False, 'False']:
|
||||||
self.post(url, {'value': value}, valid=True)
|
self.post(url, {'value': value}, valid=True)
|
||||||
self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT'))
|
self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT'))
|
||||||
|
|
||||||
|
def test_part_name_format(self):
|
||||||
|
"""
|
||||||
|
Try posting some valid and invalid name formats for PART_NAME_FORMAT
|
||||||
|
"""
|
||||||
|
setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT')
|
||||||
|
|
||||||
|
# test default value
|
||||||
|
self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}"
|
||||||
|
"{{ ' | ' if part.revision }}{{ part.revision if part.revision }}")
|
||||||
|
|
||||||
|
url = self.get_url(setting.pk)
|
||||||
|
|
||||||
|
# Try posting an invalid part name format
|
||||||
|
invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}']
|
||||||
|
for invalid_value in invalid_values:
|
||||||
|
self.post(url, {'value': invalid_value}, valid=False)
|
||||||
|
|
||||||
|
# try posting valid value
|
||||||
|
new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}"
|
||||||
|
self.post(url, {'value': new_format}, valid=True)
|
||||||
|
@ -2,10 +2,6 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
|
||||||
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
|
|
||||||
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
<a href='#' id='company-menu-toggle'>
|
<a href='#' id='company-menu-toggle'>
|
||||||
@ -32,7 +28,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if company.is_manufacturer or company.is_supplier %}
|
{% if company.is_manufacturer or company.is_supplier %}
|
||||||
{% if enable_stock %}
|
|
||||||
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
||||||
<a href='#' id='select-company-stock' class='nav-toggle'>
|
<a href='#' id='select-company-stock' class='nav-toggle'>
|
||||||
<span class='fas fa-boxes sidebar-icon'></span>
|
<span class='fas fa-boxes sidebar-icon'></span>
|
||||||
@ -40,9 +35,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company.is_supplier and enable_po %}
|
{% if company.is_supplier %}
|
||||||
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
||||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||||
<span class='fas fa-shopping-cart sidebar-icon'></span>
|
<span class='fas fa-shopping-cart sidebar-icon'></span>
|
||||||
@ -51,7 +45,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if company.is_customer and enable_so %}
|
{% if company.is_customer %}
|
||||||
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
||||||
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
||||||
<span class='fas fa-truck sidebar-icon'></span>
|
<span class='fas fa-truck sidebar-icon'></span>
|
||||||
|
@ -20,6 +20,10 @@ class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
|
|||||||
|
|
||||||
class PurchaseOrderAdmin(ImportExportModelAdmin):
|
class PurchaseOrderAdmin(ImportExportModelAdmin):
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
'reference_int',
|
||||||
|
]
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
'reference',
|
'reference',
|
||||||
'supplier',
|
'supplier',
|
||||||
@ -41,6 +45,10 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
class SalesOrderAdmin(ImportExportModelAdmin):
|
class SalesOrderAdmin(ImportExportModelAdmin):
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
'reference_int',
|
||||||
|
]
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
'reference',
|
'reference',
|
||||||
'customer',
|
'customer',
|
||||||
|
@ -152,9 +152,13 @@ class POList(generics.ListCreateAPIView):
|
|||||||
filter_backends = [
|
filter_backends = [
|
||||||
rest_filters.DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter,
|
InvenTreeOrderingFilter,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
'reference': ['reference_int', 'reference'],
|
||||||
|
}
|
||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
'supplier',
|
'supplier',
|
||||||
]
|
]
|
||||||
@ -504,9 +508,13 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
filter_backends = [
|
filter_backends = [
|
||||||
rest_filters.DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
filters.OrderingFilter,
|
InvenTreeOrderingFilter,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
'reference': ['reference_int', 'reference'],
|
||||||
|
}
|
||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
'customer',
|
'customer',
|
||||||
]
|
]
|
||||||
|
23
InvenTree/order/migrations/0051_auto_20211014_0623.py
Normal file
23
InvenTree/order/migrations/0051_auto_20211014_0623.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-10-14 06:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0050_alter_purchaseorderlineitem_destination'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='purchaseorder',
|
||||||
|
name='reference_int',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='salesorder',
|
||||||
|
name='reference_int',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
66
InvenTree/order/migrations/0052_auto_20211014_0631.py
Normal file
66
InvenTree/order/migrations/0052_auto_20211014_0631.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-10-14 06:31
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
def build_refs(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Rebuild the integer "reference fields" for existing Build objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
PurchaseOrder = apps.get_model('order', 'purchaseorder')
|
||||||
|
|
||||||
|
for order in PurchaseOrder.objects.all():
|
||||||
|
|
||||||
|
ref = 0
|
||||||
|
|
||||||
|
result = re.match(r"^(\d+)", order.reference)
|
||||||
|
|
||||||
|
if result and len(result.groups()) == 1:
|
||||||
|
try:
|
||||||
|
ref = int(result.groups()[0])
|
||||||
|
except:
|
||||||
|
ref = 0
|
||||||
|
|
||||||
|
order.reference_int = ref
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
SalesOrder = apps.get_model('order', 'salesorder')
|
||||||
|
|
||||||
|
for order in SalesOrder.objects.all():
|
||||||
|
|
||||||
|
ref = 0
|
||||||
|
|
||||||
|
result = re.match(r"^(\d+)", order.reference)
|
||||||
|
|
||||||
|
if result and len(result.groups()) == 1:
|
||||||
|
try:
|
||||||
|
ref = int(result.groups()[0])
|
||||||
|
except:
|
||||||
|
ref = 0
|
||||||
|
|
||||||
|
order.reference_int = ref
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
|
||||||
|
def unbuild_refs(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Provided only for reverse migration compatibility
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0051_auto_20211014_0623'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
build_refs,
|
||||||
|
reverse_code=unbuild_refs
|
||||||
|
)
|
||||||
|
]
|
@ -28,7 +28,7 @@ from company.models import Company, SupplierPart
|
|||||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||||
from InvenTree.helpers import decimal2string, increment, getSetting
|
from InvenTree.helpers import decimal2string, increment, getSetting
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.models import InvenTreeAttachment
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
|
|
||||||
|
|
||||||
def get_next_po_number():
|
def get_next_po_number():
|
||||||
@ -89,7 +89,7 @@ def get_next_so_number():
|
|||||||
return reference
|
return reference
|
||||||
|
|
||||||
|
|
||||||
class Order(models.Model):
|
class Order(ReferenceIndexingMixin):
|
||||||
""" Abstract model for an order.
|
""" Abstract model for an order.
|
||||||
|
|
||||||
Instances of this class:
|
Instances of this class:
|
||||||
@ -147,6 +147,9 @@ class Order(models.Model):
|
|||||||
return new_ref
|
return new_ref
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.rebuild_reference_field()
|
||||||
|
|
||||||
if not self.creation_date:
|
if not self.creation_date:
|
||||||
self.creation_date = datetime.now().date()
|
self.creation_date = datetime.now().date()
|
||||||
|
|
||||||
@ -531,6 +534,12 @@ class SalesOrder(Order):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.rebuild_reference_field()
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
||||||
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
|
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
|
||||||
|
59
InvenTree/order/test_migrations.py
Normal file
59
InvenTree/order/test_migrations.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the 'order' model data migrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
|
from InvenTree import helpers
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardMigrations(MigratorTestCase):
|
||||||
|
"""
|
||||||
|
Test entire schema migration
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('order', helpers.getOldestMigrationFile('order'))
|
||||||
|
migrate_to = ('order', helpers.getNewestMigrationFile('order'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Create initial data set
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a purchase order from a supplier
|
||||||
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
|
supplier = Company.objects.create(
|
||||||
|
name='Supplier A',
|
||||||
|
description='A great supplier!',
|
||||||
|
is_supplier=True
|
||||||
|
)
|
||||||
|
|
||||||
|
PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder')
|
||||||
|
|
||||||
|
# Create some orders
|
||||||
|
for ii in range(10):
|
||||||
|
|
||||||
|
order = PurchaseOrder.objects.create(
|
||||||
|
supplier=supplier,
|
||||||
|
reference=f"{ii}-abcde",
|
||||||
|
description="Just a test order"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initially, the 'reference_int' field is unavailable
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
print(order.reference_int)
|
||||||
|
|
||||||
|
def test_ref_field(self):
|
||||||
|
"""
|
||||||
|
Test that the 'reference_int' field has been created and is filled out correctly
|
||||||
|
"""
|
||||||
|
|
||||||
|
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
||||||
|
|
||||||
|
for ii in range(10):
|
||||||
|
|
||||||
|
order = PurchaseOrder.objects.get(reference=f"{ii}-abcde")
|
||||||
|
|
||||||
|
# The integer reference field must have been correctly updated
|
||||||
|
self.assertEqual(order.reference_int, ii)
|
@ -27,7 +27,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
|||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from .models import Part, PartCategory, BomItem
|
from .models import Part, PartCategory
|
||||||
|
from .models import BomItem, BomItemSubstitute
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
from .models import PartAttachment, PartTestTemplate
|
from .models import PartAttachment, PartTestTemplate
|
||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
@ -813,6 +814,27 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Exclude specific part ID values?
|
||||||
|
exclude_id = []
|
||||||
|
|
||||||
|
for key in ['exclude_id', 'exclude_id[]']:
|
||||||
|
if key in params:
|
||||||
|
exclude_id += params.getlist(key, [])
|
||||||
|
|
||||||
|
if exclude_id:
|
||||||
|
|
||||||
|
id_values = []
|
||||||
|
|
||||||
|
for val in exclude_id:
|
||||||
|
try:
|
||||||
|
# pk values must be integer castable
|
||||||
|
val = int(val)
|
||||||
|
id_values.append(val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
queryset = queryset.exclude(pk__in=id_values)
|
||||||
|
|
||||||
# Exclude part variant tree?
|
# Exclude part variant tree?
|
||||||
exclude_tree = params.get('exclude_tree', None)
|
exclude_tree = params.get('exclude_tree', None)
|
||||||
|
|
||||||
@ -1078,11 +1100,23 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
else:
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
|
||||||
data = serializer.data
|
data = serializer.data
|
||||||
|
|
||||||
if request.is_ajax():
|
"""
|
||||||
|
Determine the response type based on the request.
|
||||||
|
a) For HTTP requests (e.g. via the browseable API) return a DRF response
|
||||||
|
b) For AJAX requests, simply return a JSON rendered response.
|
||||||
|
"""
|
||||||
|
if page is not None:
|
||||||
|
return self.get_paginated_response(data)
|
||||||
|
elif request.is_ajax():
|
||||||
return JsonResponse(data, safe=False)
|
return JsonResponse(data, safe=False)
|
||||||
else:
|
else:
|
||||||
return Response(data)
|
return Response(data)
|
||||||
@ -1102,7 +1136,7 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Include or exclude pricing information in the serialized data
|
# Include or exclude pricing information in the serialized data
|
||||||
kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True))
|
kwargs['include_pricing'] = self.include_pricing()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -1147,13 +1181,19 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
include_pricing = str2bool(params.get('include_pricing', True))
|
if self.include_pricing():
|
||||||
|
|
||||||
if include_pricing:
|
|
||||||
queryset = self.annotate_pricing(queryset)
|
queryset = self.annotate_pricing(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def include_pricing(self):
|
||||||
|
"""
|
||||||
|
Determine if pricing information should be included in the response
|
||||||
|
"""
|
||||||
|
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
|
||||||
|
|
||||||
|
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
|
||||||
|
|
||||||
def annotate_pricing(self, queryset):
|
def annotate_pricing(self, queryset):
|
||||||
"""
|
"""
|
||||||
Add part pricing information to the queryset
|
Add part pricing information to the queryset
|
||||||
@ -1262,6 +1302,35 @@ class BomItemValidate(generics.UpdateAPIView):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class BomItemSubstituteList(generics.ListCreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for accessing a list of BomItemSubstitute objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||||
|
queryset = BomItemSubstitute.objects.all()
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend,
|
||||||
|
filters.SearchFilter,
|
||||||
|
filters.OrderingFilter,
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'part',
|
||||||
|
'bom_item',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomItemSubstituteDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for detail view of a single BomItemSubstitute object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = BomItemSubstitute.objects.all()
|
||||||
|
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||||
|
|
||||||
|
|
||||||
part_api_urls = [
|
part_api_urls = [
|
||||||
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
|
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
|
||||||
|
|
||||||
@ -1314,6 +1383,16 @@ part_api_urls = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
bom_api_urls = [
|
bom_api_urls = [
|
||||||
|
|
||||||
|
url(r'^substitute/', include([
|
||||||
|
|
||||||
|
# Detail view
|
||||||
|
url(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
|
||||||
|
|
||||||
|
# Catch all
|
||||||
|
url(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
# BOM Item Detail
|
# BOM Item Detail
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
|
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
|
||||||
|
22
InvenTree/part/migrations/0072_bomitemsubstitute.py
Normal file
22
InvenTree/part/migrations/0072_bomitemsubstitute.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-10-12 23:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0071_alter_partparametertemplate_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BomItemSubstitute',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('bom_item', models.ForeignKey(help_text='Parent BOM item', on_delete=django.db.models.deletion.CASCADE, related_name='substitutes', to='part.bomitem', verbose_name='BOM Item')),
|
||||||
|
('part', models.ForeignKey(help_text='Substitute part', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='substitute_items', to='part.part', verbose_name='Part')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
21
InvenTree/part/migrations/0073_auto_20211013_1048.py
Normal file
21
InvenTree/part/migrations/0073_auto_20211013_1048.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-10-13 10:48
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0072_bomitemsubstitute'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='bomitemsubstitute',
|
||||||
|
options={'verbose_name': 'BOM Item Substitute'},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='bomitemsubstitute',
|
||||||
|
unique_together={('part', 'bom_item')},
|
||||||
|
),
|
||||||
|
]
|
@ -23,6 +23,8 @@ from django.contrib.auth.models import User
|
|||||||
from django.db.models.signals import pre_delete
|
from django.db.models.signals import pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
from markdownx.models import MarkdownxField
|
from markdownx.models import MarkdownxField
|
||||||
|
|
||||||
from django_cleanup import cleanup
|
from django_cleanup import cleanup
|
||||||
@ -38,6 +40,7 @@ from datetime import datetime
|
|||||||
import hashlib
|
import hashlib
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
from InvenTree import validators
|
from InvenTree import validators
|
||||||
@ -555,7 +558,9 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self):
|
||||||
""" Format a 'full name' for this Part.
|
""" Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in Inventree settings
|
||||||
|
|
||||||
|
As a failsafe option, the following is done
|
||||||
|
|
||||||
- IPN (if not null)
|
- IPN (if not null)
|
||||||
- Part name
|
- Part name
|
||||||
@ -564,6 +569,20 @@ class Part(MPTTModel):
|
|||||||
Elements are joined by the | character
|
Elements are joined by the | character
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
full_name_pattern = InvenTreeSetting.get_setting('PART_NAME_FORMAT')
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = {'part': self}
|
||||||
|
template_string = Template(full_name_pattern)
|
||||||
|
full_name = template_string.render(context)
|
||||||
|
|
||||||
|
return full_name
|
||||||
|
|
||||||
|
except AttributeError as attr_err:
|
||||||
|
|
||||||
|
logger.warning(f"exception while trying to create full name for part {self.name}", attr_err)
|
||||||
|
|
||||||
|
# Fallback to default format
|
||||||
elements = []
|
elements = []
|
||||||
|
|
||||||
if self.IPN:
|
if self.IPN:
|
||||||
@ -2333,22 +2352,48 @@ class BomItem(models.Model):
|
|||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-bom-list')
|
return reverse('api-bom-list')
|
||||||
|
|
||||||
|
def get_valid_parts_for_allocation(self):
|
||||||
|
"""
|
||||||
|
Return a list of valid parts which can be allocated against this BomItem:
|
||||||
|
|
||||||
|
- Include the referenced sub_part
|
||||||
|
- Include any directly specvified substitute parts
|
||||||
|
- If allow_variants is True, allow all variants of sub_part
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set of parts we will allow
|
||||||
|
parts = set()
|
||||||
|
|
||||||
|
parts.add(self.sub_part)
|
||||||
|
|
||||||
|
# Variant parts (if allowed)
|
||||||
|
if self.allow_variants:
|
||||||
|
for variant in self.sub_part.get_descendants(include_self=False):
|
||||||
|
parts.add(variant)
|
||||||
|
|
||||||
|
# Substitute parts
|
||||||
|
for sub in self.substitutes.all():
|
||||||
|
parts.add(sub.part)
|
||||||
|
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def is_stock_item_valid(self, stock_item):
|
||||||
|
"""
|
||||||
|
Check if the provided StockItem object is "valid" for assignment against this BomItem
|
||||||
|
"""
|
||||||
|
|
||||||
|
return stock_item.part in self.get_valid_parts_for_allocation()
|
||||||
|
|
||||||
def get_stock_filter(self):
|
def get_stock_filter(self):
|
||||||
"""
|
"""
|
||||||
Return a queryset filter for selecting StockItems which match this BomItem
|
Return a queryset filter for selecting StockItems which match this BomItem
|
||||||
|
|
||||||
|
- Allow stock from all directly specified substitute parts
|
||||||
- If allow_variants is True, allow all part variants
|
- If allow_variants is True, allow all part variants
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Target part
|
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
|
||||||
part = self.sub_part
|
|
||||||
|
|
||||||
if self.allow_variants:
|
|
||||||
variants = part.get_descendants(include_self=True)
|
|
||||||
return Q(part__in=[v.pk for v in variants])
|
|
||||||
else:
|
|
||||||
return Q(part=part)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -2613,6 +2658,66 @@ class BomItem(models.Model):
|
|||||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
||||||
|
|
||||||
|
|
||||||
|
class BomItemSubstitute(models.Model):
|
||||||
|
"""
|
||||||
|
A BomItemSubstitute provides a specification for alternative parts,
|
||||||
|
which can be used in a bill of materials.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
bom_item: Link to the parent BomItem instance
|
||||||
|
part: The part which can be used as a substitute
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("BOM Item Substitute")
|
||||||
|
|
||||||
|
# Prevent duplication of substitute parts
|
||||||
|
unique_together = ('part', 'bom_item')
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
self.full_clean()
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def validate_unique(self, exclude=None):
|
||||||
|
"""
|
||||||
|
Ensure that this BomItemSubstitute is "unique":
|
||||||
|
|
||||||
|
- It cannot point to the same "part" as the "sub_part" of the parent "bom_item"
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().validate_unique(exclude=exclude)
|
||||||
|
|
||||||
|
if self.part == self.bom_item.sub_part:
|
||||||
|
raise ValidationError({
|
||||||
|
"part": _("Substitute part cannot be the same as the master part"),
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_api_url():
|
||||||
|
return reverse('api-bom-substitute-list')
|
||||||
|
|
||||||
|
bom_item = models.ForeignKey(
|
||||||
|
BomItem,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='substitutes',
|
||||||
|
verbose_name=_('BOM Item'),
|
||||||
|
help_text=_('Parent BOM item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
part = models.ForeignKey(
|
||||||
|
Part,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='substitute_items',
|
||||||
|
verbose_name=_('Part'),
|
||||||
|
help_text=_('Substitute part'),
|
||||||
|
limit_choices_to={
|
||||||
|
'component': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PartRelated(models.Model):
|
class PartRelated(models.Model):
|
||||||
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
|
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
|
||||||
|
|
||||||
|
@ -23,7 +23,8 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
|||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
from .models import (BomItem, BomItemSubstitute,
|
||||||
|
Part, PartAttachment, PartCategory,
|
||||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||||
PartInternalPriceBreak)
|
PartInternalPriceBreak)
|
||||||
@ -388,8 +389,27 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for the BomItemSubstitute class
|
||||||
|
"""
|
||||||
|
|
||||||
|
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BomItemSubstitute
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'bom_item',
|
||||||
|
'part',
|
||||||
|
'part_detail',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class BomItemSerializer(InvenTreeModelSerializer):
|
class BomItemSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for BomItem object """
|
"""
|
||||||
|
Serializer for BomItem object
|
||||||
|
"""
|
||||||
|
|
||||||
price_range = serializers.CharField(read_only=True)
|
price_range = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||||
|
|
||||||
|
substitutes = BomItemSubstituteSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
|
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
|
||||||
@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
'reference',
|
'reference',
|
||||||
'sub_part',
|
'sub_part',
|
||||||
'sub_part_detail',
|
'sub_part_detail',
|
||||||
|
'substitutes',
|
||||||
'price_range',
|
'price_range',
|
||||||
'validated',
|
'validated',
|
||||||
]
|
]
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% if roles.part.change != True and editing_enabled %}
|
{% if not roles.part.change %}
|
||||||
<div class='alert alert-danger alert-block'>
|
<div class='alert alert-danger alert-block'>
|
||||||
{% trans "You do not have permission to edit the BOM." %}
|
{% trans "You do not have permission to edit the BOM." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endif %}
|
||||||
|
|
||||||
{% if part.bom_checked_date %}
|
{% if part.bom_checked_date %}
|
||||||
{% if part.is_bom_valid %}
|
{% if part.is_bom_valid %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
@ -23,42 +24,38 @@
|
|||||||
|
|
||||||
<div id='bom-button-toolbar'>
|
<div id='bom-button-toolbar'>
|
||||||
<div class="btn-group" role="group" aria-label="...">
|
<div class="btn-group" role="group" aria-label="...">
|
||||||
{% if editing_enabled %}
|
<!-- Export menu -->
|
||||||
<button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
|
<div class='btn-group'>
|
||||||
<span class='fas fa-trash-alt icon-red'></span>
|
<button id='export-options' title='{% trans "Export actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
|
<span class='fas fa-download'></span> <span class='caret'></span>
|
||||||
</button>
|
</button>
|
||||||
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
<span class='fas fa-file-upload'></span>
|
<li><a href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
|
||||||
</button>
|
<li><a href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
|
||||||
{% if part.variant_of %}
|
</ul>
|
||||||
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
|
</div>
|
||||||
<span class='fas fa-clone'></span>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
|
||||||
<span class='fas fa-plus-circle'></span>
|
|
||||||
</button>
|
|
||||||
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
|
|
||||||
<span class='fas fa-check-circle'></span>
|
|
||||||
</button>
|
|
||||||
{% elif part.active %}
|
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
|
<!-- Action menu -->
|
||||||
<span class='fas fa-edit'></span>
|
<div class='btn-group'>
|
||||||
|
<button id='bom-actions' title='{% trans "BOM actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
|
<span class='fas fa-wrench'></span> <span class='caret'></span>
|
||||||
</button>
|
</button>
|
||||||
{% if part.is_bom_valid == False %}
|
<ul class='dropdown-menu' role='menu'>
|
||||||
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
|
<li><a href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
|
||||||
<span class='fas fa-clipboard-check'></span>
|
{% if part.variant_of %}
|
||||||
|
<li><a href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if not part.is_bom_valid %}
|
||||||
|
<li><a href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a href='#' id='bom-item-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Items" %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button class='btn btn-success' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
|
|
||||||
<span class='fas fa-file-download'></span>
|
|
||||||
</button>
|
|
||||||
<button title='{% trans "Print BOM Report" %}' class='btn btn-default' id='print-bom-report' type='button'>
|
|
||||||
<span class='fas fa-file-pdf'></span>
|
|
||||||
</button>
|
|
||||||
<div class='filter-list' id='filter-list-bom'>
|
<div class='filter-list' id='filter-list-bom'>
|
||||||
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
||||||
</div>
|
</div>
|
||||||
@ -67,4 +64,3 @@
|
|||||||
|
|
||||||
<table class='table table-bom table-condensed' data-toolbar="#bom-button-toolbar" id='bom-table'>
|
<table class='table table-bom table-condensed' data-toolbar="#bom-button-toolbar" id='bom-table'>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
|
@ -473,7 +473,11 @@
|
|||||||
onPanelLoad("bom", function() {
|
onPanelLoad("bom", function() {
|
||||||
// Load the BOM table data
|
// Load the BOM table data
|
||||||
loadBomTable($("#bom-table"), {
|
loadBomTable($("#bom-table"), {
|
||||||
editable: {{ editing_enabled }},
|
{% if roles.part.change %}
|
||||||
|
editable: true,
|
||||||
|
{% else %}
|
||||||
|
editable: false,
|
||||||
|
{% endif %}
|
||||||
bom_url: "{% url 'api-bom-list' %}",
|
bom_url: "{% url 'api-bom-list' %}",
|
||||||
part_url: "{% url 'api-part-list' %}",
|
part_url: "{% url 'api-part-list' %}",
|
||||||
parent_id: {{ part.id }} ,
|
parent_id: {{ part.id }} ,
|
||||||
@ -486,11 +490,6 @@
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
{% if editing_enabled %}
|
|
||||||
$("#editing-finished").click(function() {
|
|
||||||
location.href = "{% url 'part-detail' part.id %}?display=bom";
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#bom-item-delete').click(function() {
|
$('#bom-item-delete').click(function() {
|
||||||
|
|
||||||
// Get a list of the selected BOM items
|
// Get a list of the selected BOM items
|
||||||
@ -559,8 +558,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
$("#validate-bom").click(function() {
|
$("#validate-bom").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'bom-validate' part.id %}",
|
"{% url 'bom-validate' part.id %}",
|
||||||
@ -570,10 +567,6 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#edit-bom").click(function () {
|
|
||||||
location.href = "{% url 'part-detail' part.id %}?display=bom&edit=1";
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#download-bom").click(function () {
|
$("#download-bom").click(function () {
|
||||||
launchModalForm("{% url 'bom-export' part.id %}",
|
launchModalForm("{% url 'bom-export' part.id %}",
|
||||||
{
|
{
|
||||||
@ -584,8 +577,6 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
$("#print-bom-report").click(function() {
|
$("#print-bom-report").click(function() {
|
||||||
printBomReports([{{ part.pk }}]);
|
printBomReports([{{ part.pk }}]);
|
||||||
});
|
});
|
||||||
@ -629,10 +620,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Load the BOM table data in the pricing view
|
// Load the BOM table data in the pricing view
|
||||||
loadBomTable($("#bom-pricing-table"), {
|
loadBomTable($("#bom-pricing-table"), {
|
||||||
editable: {{ editing_enabled }},
|
editable: false,
|
||||||
bom_url: "{% url 'api-bom-list' %}",
|
bom_url: "{% url 'api-bom-list' %}",
|
||||||
part_url: "{% url 'api-part-list' %}",
|
part_url: "{% url 'api-part-list' %}",
|
||||||
parent_id: {{ part.id }} ,
|
parent_id: {{ part.id }} ,
|
||||||
|
@ -4,12 +4,6 @@
|
|||||||
|
|
||||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||||
{% settings_value 'BUILD_FUNCTION_ENABLE' as enable_build %}
|
|
||||||
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
|
||||||
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
|
||||||
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
|
|
||||||
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
|
|
||||||
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
|
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
@ -31,14 +25,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if enable_stock %}
|
|
||||||
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
||||||
<a href='#' id='select-part-stock' class='nav-toggle'>
|
<a href='#' id='select-part-stock' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
|
||||||
{% trans "Stock" %}
|
{% trans "Stock" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
{% if part.assembly %}
|
{% if part.assembly %}
|
||||||
<li class='list-group-item' title='{% trans "Bill of Materials" %}'>
|
<li class='list-group-item' title='{% trans "Bill of Materials" %}'>
|
||||||
<a href='#' id='select-bom' class='nav-toggle'>
|
<a href='#' id='select-bom' class='nav-toggle'>
|
||||||
@ -46,7 +38,7 @@
|
|||||||
{% trans "Bill of Materials" %}
|
{% trans "Bill of Materials" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if roles.build.view and enable_build %}
|
{% if roles.build.view %}
|
||||||
<li class='list-group-item ' title='{% trans "Build Orders" %}'>
|
<li class='list-group-item ' title='{% trans "Build Orders" %}'>
|
||||||
<a href='#' id='select-build-orders' class='nav-toggle'>
|
<a href='#' id='select-build-orders' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
|
||||||
@ -63,22 +55,19 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if enable_buy or enable_sell %}
|
|
||||||
<li class='list-group-item' title='{% trans "Pricing Information" %}'>
|
<li class='list-group-item' title='{% trans "Pricing Information" %}'>
|
||||||
<a href='#' id='select-pricing' class='nav-toggle'>
|
<a href='#' id='select-pricing' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||||
{% trans "Prices" %}
|
{% trans "Prices" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% if part.purchaseable and roles.purchase_order.view %}
|
||||||
{% if part.purchaseable and roles.purchase_order.view and enable_buy %}
|
|
||||||
<li class='list-group-item' title='{% trans "Suppliers" %}'>
|
<li class='list-group-item' title='{% trans "Suppliers" %}'>
|
||||||
<a href='#' id='select-suppliers' class='nav-toggle'>
|
<a href='#' id='select-suppliers' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
|
||||||
{% trans "Suppliers" %}
|
{% trans "Suppliers" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if enable_po %}
|
|
||||||
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
||||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
|
||||||
@ -86,8 +75,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% if part.salable and roles.sales_order.view %}
|
||||||
{% if part.salable and roles.sales_order.view and enable_sell and enable_so %}
|
|
||||||
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
||||||
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
||||||
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span>
|
||||||
|
@ -10,11 +10,6 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
|
|
||||||
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
|
|
||||||
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
|
||||||
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
|
||||||
|
|
||||||
<div class="panel panel-default panel-inventree">
|
<div class="panel panel-default panel-inventree">
|
||||||
<!-- Default panel contents -->
|
<!-- Default panel contents -->
|
||||||
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
|
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
|
||||||
@ -85,12 +80,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.active %}
|
{% if part.active %}
|
||||||
{% if enable_buy or enable_sell %}
|
|
||||||
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
|
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
|
||||||
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% if roles.stock.change %}
|
||||||
{% if roles.stock.change and enable_stock %}
|
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
<span class='fas fa-boxes'></span> <span class='caret'></span>
|
<span class='fas fa-boxes'></span> <span class='caret'></span>
|
||||||
@ -112,13 +105,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.purchaseable and roles.purchase_order.add %}
|
{% if part.purchaseable and roles.purchase_order.add %}
|
||||||
{% if enable_buy and enable_po %}
|
|
||||||
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
|
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
|
||||||
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
<!-- Part actions -->
|
<!-- Part actions -->
|
||||||
{% if roles.part.add or roles.part.change or roles.part.delete %}
|
{% if roles.part.add or roles.part.change or roles.part.delete %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
|
||||||
@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
|||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from stock.models import StockItem
|
from part.models import BomItem, BomItemSubstitute
|
||||||
|
from stock.models import StockItem, StockLocation
|
||||||
from company.models import Company
|
from company.models import Company
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 3)
|
self.assertEqual(len(response.data), 3)
|
||||||
|
|
||||||
def test_get_bom_list(self):
|
|
||||||
""" There should be 4 BomItem objects in the database """
|
|
||||||
url = reverse('api-bom-list')
|
|
||||||
response = self.client.get(url, format='json')
|
|
||||||
self.assertEqual(len(response.data), 5)
|
|
||||||
|
|
||||||
def test_get_bom_detail(self):
|
|
||||||
# Get the detail for a single BomItem
|
|
||||||
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
|
|
||||||
response = self.client.get(url, format='json')
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(int(float(response.data['quantity'])), 25)
|
|
||||||
|
|
||||||
# Increase the quantity
|
|
||||||
data = response.data
|
|
||||||
data['quantity'] = 57
|
|
||||||
data['note'] = 'Added a note'
|
|
||||||
|
|
||||||
response = self.client.patch(url, data, format='json')
|
|
||||||
|
|
||||||
# Check that the quantity was increased and a note added
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(int(float(response.data['quantity'])), 57)
|
|
||||||
self.assertEqual(response.data['note'], 'Added a note')
|
|
||||||
|
|
||||||
def test_add_bom_item(self):
|
|
||||||
url = reverse('api-bom-list')
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'part': 100,
|
|
||||||
'sub_part': 4,
|
|
||||||
'quantity': 777,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(url, data, format='json')
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
# Now try to create a BomItem which points to a non-assembly part (should fail)
|
|
||||||
data['part'] = 3
|
|
||||||
response = self.client.post(url, data, format='json')
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# TODO - Now try to create a BomItem which references itself
|
|
||||||
data['part'] = 2
|
|
||||||
data['sub_part'] = 2
|
|
||||||
response = self.client.post(url, data, format='json')
|
|
||||||
|
|
||||||
def test_test_templates(self):
|
def test_test_templates(self):
|
||||||
|
|
||||||
url = reverse('api-part-test-template-list')
|
url = reverse('api-part-test-template-list')
|
||||||
@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(data['stock_item_count'], 105)
|
self.assertEqual(data['stock_item_count'], 105)
|
||||||
|
|
||||||
|
|
||||||
|
class BomItemTest(InvenTreeAPITestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for the BomItem API
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'stock',
|
||||||
|
'bom',
|
||||||
|
'company',
|
||||||
|
]
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'part.add',
|
||||||
|
'part.change',
|
||||||
|
'part.delete',
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
def test_bom_list(self):
|
||||||
|
"""
|
||||||
|
Tests for the BomItem list endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
# How many BOM items currently exist in the database?
|
||||||
|
n = BomItem.objects.count()
|
||||||
|
|
||||||
|
url = reverse('api-bom-list')
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), n)
|
||||||
|
|
||||||
|
# Now, filter by part
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'part': 100,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
print("results:", len(response.data))
|
||||||
|
|
||||||
|
def test_get_bom_detail(self):
|
||||||
|
"""
|
||||||
|
Get the detail view for a single BomItem object
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(int(float(response.data['quantity'])), 25)
|
||||||
|
|
||||||
|
# Increase the quantity
|
||||||
|
data = response.data
|
||||||
|
data['quantity'] = 57
|
||||||
|
data['note'] = 'Added a note'
|
||||||
|
|
||||||
|
response = self.patch(url, data, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(int(float(response.data['quantity'])), 57)
|
||||||
|
self.assertEqual(response.data['note'], 'Added a note')
|
||||||
|
|
||||||
|
def test_add_bom_item(self):
|
||||||
|
"""
|
||||||
|
Test that we can create a new BomItem via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-bom-list')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'part': 100,
|
||||||
|
'sub_part': 4,
|
||||||
|
'quantity': 777,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
# Now try to create a BomItem which references itself
|
||||||
|
data['part'] = 100
|
||||||
|
data['sub_part'] = 100
|
||||||
|
self.client.post(url, data, expected_code=400)
|
||||||
|
|
||||||
|
def test_variants(self):
|
||||||
|
"""
|
||||||
|
Tests for BomItem use with variants
|
||||||
|
"""
|
||||||
|
|
||||||
|
stock_url = reverse('api-stock-list')
|
||||||
|
|
||||||
|
# BOM item we are interested in
|
||||||
|
bom_item = BomItem.objects.get(pk=1)
|
||||||
|
|
||||||
|
bom_item.allow_variants = True
|
||||||
|
bom_item.save()
|
||||||
|
|
||||||
|
# sub part that the BOM item points to
|
||||||
|
sub_part = bom_item.sub_part
|
||||||
|
|
||||||
|
sub_part.is_template = True
|
||||||
|
sub_part.save()
|
||||||
|
|
||||||
|
# How many stock items are initially available for this part?
|
||||||
|
response = self.get(
|
||||||
|
stock_url,
|
||||||
|
{
|
||||||
|
'bom_item': bom_item.pk,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
n_items = len(response.data)
|
||||||
|
self.assertEqual(n_items, 2)
|
||||||
|
|
||||||
|
loc = StockLocation.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Now we will create some variant parts and stock
|
||||||
|
for ii in range(5):
|
||||||
|
|
||||||
|
# Create a variant part!
|
||||||
|
variant = Part.objects.create(
|
||||||
|
name=f"Variant_{ii}",
|
||||||
|
description="A variant part",
|
||||||
|
component=True,
|
||||||
|
variant_of=sub_part
|
||||||
|
)
|
||||||
|
|
||||||
|
variant.save()
|
||||||
|
|
||||||
|
Part.objects.rebuild()
|
||||||
|
|
||||||
|
# Create some stock items for this new part
|
||||||
|
for jj in range(ii):
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=variant,
|
||||||
|
location=loc,
|
||||||
|
quantity=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep track of running total
|
||||||
|
n_items += ii
|
||||||
|
|
||||||
|
# Now, there should be more stock items available!
|
||||||
|
response = self.get(
|
||||||
|
stock_url,
|
||||||
|
{
|
||||||
|
'bom_item': bom_item.pk,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), n_items)
|
||||||
|
|
||||||
|
# Now, disallow variant parts in the BomItem
|
||||||
|
bom_item.allow_variants = False
|
||||||
|
bom_item.save()
|
||||||
|
|
||||||
|
# There should now only be 2 stock items available again
|
||||||
|
response = self.get(
|
||||||
|
stock_url,
|
||||||
|
{
|
||||||
|
'bom_item': bom_item.pk,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
def test_substitutes(self):
|
||||||
|
"""
|
||||||
|
Tests for BomItem substitutes
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-bom-substitute-list')
|
||||||
|
stock_url = reverse('api-stock-list')
|
||||||
|
|
||||||
|
# Initially we have no substitute parts
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
# BOM item we are interested in
|
||||||
|
bom_item = BomItem.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Filter stock items which can be assigned against this stock item
|
||||||
|
response = self.get(
|
||||||
|
stock_url,
|
||||||
|
{
|
||||||
|
"bom_item": bom_item.pk,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
n_items = len(response.data)
|
||||||
|
|
||||||
|
loc = StockLocation.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Let's make some!
|
||||||
|
for ii in range(5):
|
||||||
|
sub_part = Part.objects.create(
|
||||||
|
name=f"Substitute {ii}",
|
||||||
|
description="A substitute part",
|
||||||
|
component=True,
|
||||||
|
is_template=False,
|
||||||
|
assembly=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new StockItem for this Part
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=sub_part,
|
||||||
|
quantity=1000,
|
||||||
|
location=loc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now, create an "alternative" for the BOM Item
|
||||||
|
BomItemSubstitute.objects.create(
|
||||||
|
bom_item=bom_item,
|
||||||
|
part=sub_part
|
||||||
|
)
|
||||||
|
|
||||||
|
# We should be able to filter the API list to just return this new part
|
||||||
|
response = self.get(url, data={'part': sub_part.pk}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
|
# We should also have more stock available to allocate against this BOM item!
|
||||||
|
response = self.get(
|
||||||
|
stock_url,
|
||||||
|
{
|
||||||
|
"bom_item": bom_item.pk,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), n_items + ii + 1)
|
||||||
|
|
||||||
|
# There should now be 5 substitute parts available in the database
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
|
|
||||||
class PartParameterTest(InvenTreeAPITestCase):
|
class PartParameterTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
Tests for the ParParameter API
|
Tests for the ParParameter API
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import Part, BomItem
|
from .models import Part, BomItem, BomItemSubstitute
|
||||||
|
|
||||||
|
|
||||||
class BomItemTest(TestCase):
|
class BomItemTest(TestCase):
|
||||||
@ -130,3 +135,67 @@ class BomItemTest(TestCase):
|
|||||||
self.bob.get_bom_price_range(1, internal=True),
|
self.bob.get_bom_price_range(1, internal=True),
|
||||||
(Decimal(27.5), Decimal(87.5))
|
(Decimal(27.5), Decimal(87.5))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_substitutes(self):
|
||||||
|
"""
|
||||||
|
Tests for BOM item substitutes
|
||||||
|
"""
|
||||||
|
|
||||||
|
# We will make some subtitute parts for the "orphan" part
|
||||||
|
bom_item = BomItem.objects.get(
|
||||||
|
part=self.bob,
|
||||||
|
sub_part=self.orphan
|
||||||
|
)
|
||||||
|
|
||||||
|
# No substitute parts available
|
||||||
|
self.assertEqual(bom_item.substitutes.count(), 0)
|
||||||
|
|
||||||
|
subs = []
|
||||||
|
|
||||||
|
for ii in range(5):
|
||||||
|
|
||||||
|
# Create a new part
|
||||||
|
sub_part = Part.objects.create(
|
||||||
|
name=f"Orphan {ii}",
|
||||||
|
description="A substitute part for the orphan part",
|
||||||
|
component=True,
|
||||||
|
is_template=False,
|
||||||
|
assembly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
subs.append(sub_part)
|
||||||
|
|
||||||
|
# Link it as a substitute part
|
||||||
|
BomItemSubstitute.objects.create(
|
||||||
|
bom_item=bom_item,
|
||||||
|
part=sub_part
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to link it again (this should fail as it is a duplicate substitute)
|
||||||
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
|
with transaction.atomic():
|
||||||
|
BomItemSubstitute.objects.create(
|
||||||
|
bom_item=bom_item,
|
||||||
|
part=sub_part
|
||||||
|
)
|
||||||
|
|
||||||
|
# There should be now 5 substitute parts available
|
||||||
|
self.assertEqual(bom_item.substitutes.count(), 5)
|
||||||
|
|
||||||
|
# Try to create a substitute which points to the same sub-part (should fail)
|
||||||
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
|
BomItemSubstitute.objects.create(
|
||||||
|
bom_item=bom_item,
|
||||||
|
part=self.orphan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove one substitute part
|
||||||
|
bom_item.substitutes.last().delete()
|
||||||
|
|
||||||
|
self.assertEqual(bom_item.substitutes.count(), 4)
|
||||||
|
|
||||||
|
for sub in subs:
|
||||||
|
sub.delete()
|
||||||
|
|
||||||
|
# The substitution links should have been automatically removed
|
||||||
|
self.assertEqual(bom_item.substitutes.count(), 0)
|
||||||
|
@ -87,16 +87,6 @@ class PartDetailTest(PartViewTestCase):
|
|||||||
self.assertEqual(response.context['part'].pk, pk)
|
self.assertEqual(response.context['part'].pk, pk)
|
||||||
self.assertEqual(response.context['category'], part.category)
|
self.assertEqual(response.context['category'], part.category)
|
||||||
|
|
||||||
self.assertFalse(response.context['editing_enabled'])
|
|
||||||
|
|
||||||
def test_editable(self):
|
|
||||||
|
|
||||||
pk = 1
|
|
||||||
response = self.client.get(reverse('part-detail', args=(pk,)), {'edit': True})
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.context['editing_enabled'])
|
|
||||||
|
|
||||||
def test_part_detail_from_ipn(self):
|
def test_part_detail_from_ipn(self):
|
||||||
"""
|
"""
|
||||||
Test that we can retrieve a part detail page from part IPN:
|
Test that we can retrieve a part detail page from part IPN:
|
||||||
|
@ -404,20 +404,13 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
|
|
||||||
# Add in some extra context information based on query params
|
# Add in some extra context information based on query params
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
""" Provide extra context data to template
|
"""
|
||||||
|
Provide extra context data to template
|
||||||
- If '?editing=True', set 'editing_enabled' context variable
|
|
||||||
"""
|
"""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
|
|
||||||
if str2bool(self.request.GET.get('edit', '')):
|
|
||||||
# Allow BOM editing if the part is active
|
|
||||||
context['editing_enabled'] = 1 if part.active else 0
|
|
||||||
else:
|
|
||||||
context['editing_enabled'] = 0
|
|
||||||
|
|
||||||
ctx = part.get_context_data(self.request)
|
ctx = part.get_context_data(self.request)
|
||||||
context.update(**ctx)
|
context.update(**ctx)
|
||||||
|
|
||||||
|
@ -72,7 +72,9 @@
|
|||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
|
<span class='fas fa-qrcode'></span> <span class='caret'></span>
|
||||||
|
</button>
|
||||||
<ul class='dropdown-menu' role='menu'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||||
{% if roles.stock.change %}
|
{% if roles.stock.change %}
|
||||||
|
@ -13,8 +13,6 @@
|
|||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUILD_FUNCTION_ENABLE" icon="fa-check" %}
|
|
||||||
<tr><td colspan='5'></td></tr>
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %}
|
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
|
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
|
||||||
@ -22,9 +21,11 @@
|
|||||||
<td>{% trans 'Signup' %}</td>
|
<td>{% trans 'Signup' %}</td>
|
||||||
<td colspan='4'></td>
|
<td colspan='4'></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
||||||
|
@ -11,9 +11,6 @@
|
|||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="PO_FUNCTION_ENABLE" icon="fa-check" %}
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUY_FUNCTION_ENABLE" icon="fa-check" %}
|
|
||||||
<tr><td colspan='5'></td></tr>
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %}
|
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -12,9 +12,6 @@
|
|||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="SO_FUNCTION_ENABLE" icon="fa-check" %}
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="SELL_FUNCTION_ENABLE" icon="fa-check" %}
|
|
||||||
<tr><td colspan='5'></td></tr>
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
|
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -12,8 +12,6 @@
|
|||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_FUNCTION_ENABLE" icon="fa-check" %}
|
|
||||||
<tr><td colspan='5'></td></tr>
|
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
||||||
|
@ -39,12 +39,12 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "E-Mail" %}</h4>
|
<h4>{% trans "Email" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{% if user.emailaddress_set.all %}
|
{% if user.emailaddress_set.all %}
|
||||||
<p>{% trans 'The following e-mail addresses are associated with your account:' %}</p>
|
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
|
||||||
|
|
||||||
<form action="{% url 'account_email' %}" class="email_list" method="post">
|
<form action="{% url 'account_email' %}" class="email_list" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -78,19 +78,19 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p><strong>{% trans 'Warning:'%}</strong>
|
<p><strong>{% trans 'Warning:'%}</strong>
|
||||||
{% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
|
{% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_add_email %}
|
{% if can_add_email %}
|
||||||
<br>
|
<br>
|
||||||
<h4>{% trans "Add E-mail Address" %}</h4>
|
<h4>{% trans "Add Email Address" %}</h4>
|
||||||
|
|
||||||
<form method="post" action="{% url 'account_email' %}" class="add_email">
|
<form method="post" action="{% url 'account_email' %}" class="add_email">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ add_email_form|crispy }}
|
{{ add_email_form|crispy }}
|
||||||
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add E-mail" %}</button>
|
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br>
|
<br>
|
||||||
@ -220,7 +220,7 @@
|
|||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
(function() {
|
(function() {
|
||||||
var message = "{% trans 'Do you really want to remove the selected e-mail address?' %}";
|
var message = "{% trans 'Do you really want to remove the selected email address?' %}";
|
||||||
var actions = document.getElementsByName('action_remove');
|
var actions = document.getElementsByName('action_remove');
|
||||||
if (actions.length) {
|
if (actions.length) {
|
||||||
actions[0].addEventListener("click", function(e) {
|
actions[0].addEventListener("click", function(e) {
|
||||||
|
@ -3,17 +3,17 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load account %}
|
{% load account %}
|
||||||
|
|
||||||
{% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %}
|
{% block head_title %}{% trans "Confirm Email Address" %}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% trans "Confirm E-mail Address" %}</h1>
|
<h1>{% trans "Confirm Email Address" %}</h1>
|
||||||
|
|
||||||
{% if confirmation %}
|
{% if confirmation %}
|
||||||
|
|
||||||
{% user_display confirmation.email_address.user as user_display %}
|
{% user_display confirmation.email_address.user as user_display %}
|
||||||
|
|
||||||
<p>{% blocktrans with confirmation.email_address.email as email %}Please confirm that <a href="mailto:{{ email }}">{{ email }}</a> is an e-mail address for user {{ user_display }}.{% endblocktrans %}</p>
|
<p>{% blocktrans with confirmation.email_address.email as email %}Please confirm that <a href="mailto:{{ email }}">{{ email }}</a> is an email address for user {{ user_display }}.{% endblocktrans %}</p>
|
||||||
|
|
||||||
<form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
|
<form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
{% url 'account_email' as email_url %}
|
{% url 'account_email' as email_url %}
|
||||||
|
|
||||||
<p>{% blocktrans %}This e-mail confirmation link expired or is invalid. Please <a href="{{ email_url }}">issue a new e-mail confirmation request</a>.{% endblocktrans %}</p>
|
<p>{% blocktrans %}This email confirmation link expired or is invalid. Please <a href="{{ email_url }}">issue a new email confirmation request</a>.{% endblocktrans %}</p>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if mail_conf and enable_pwd_forgot %}
|
{% if mail_conf and enable_pwd_forgot %}
|
||||||
<p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}</p>
|
<p>{% trans "Forgotten your password? Enter your email address below, and we'll send you an email allowing you to reset it." %}</p>
|
||||||
|
|
||||||
<form method="POST" action="{% url 'account_reset_password' %}" class="password_reset">
|
<form method="POST" action="{% url 'account_reset_password' %}" class="password_reset">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -143,6 +143,174 @@ function newPartFromBomWizard(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Launch a modal dialog displaying the "substitute parts" for a particular BomItem
|
||||||
|
*
|
||||||
|
* If editable, allows substitutes to be added and deleted
|
||||||
|
*/
|
||||||
|
function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
|
||||||
|
|
||||||
|
// Reload data for the parent table
|
||||||
|
function reloadParentTable() {
|
||||||
|
if (options.table) {
|
||||||
|
options.table.bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract a list of all existing "substitute" id values
|
||||||
|
function getSubstituteIdValues(modal) {
|
||||||
|
|
||||||
|
var id_values = [];
|
||||||
|
|
||||||
|
$(modal).find('.substitute-row').each(function(el) {
|
||||||
|
var part = $(this).attr('part');
|
||||||
|
id_values.push(part);
|
||||||
|
});
|
||||||
|
|
||||||
|
return id_values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubstituteRow(substitute) {
|
||||||
|
|
||||||
|
var pk = substitute.pk;
|
||||||
|
|
||||||
|
var part = substitute.part_detail;
|
||||||
|
|
||||||
|
var thumb = thumbnailImage(part.thumbnail || part.image);
|
||||||
|
|
||||||
|
var buttons = '';
|
||||||
|
|
||||||
|
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}');
|
||||||
|
|
||||||
|
// Render a single row
|
||||||
|
var html = `
|
||||||
|
<tr id='substitute-row-${pk}' class='substitute-row' part='${substitute.part}'>
|
||||||
|
<td id='part-${pk}'>
|
||||||
|
<a href='/part/${part.pk}/'>
|
||||||
|
${thumb} ${part.full_name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td id='description-${pk}'><em>${part.description}</em></td>
|
||||||
|
<td>${buttons}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a table to render the rows
|
||||||
|
var rows = '';
|
||||||
|
|
||||||
|
substitutes.forEach(function(sub) {
|
||||||
|
rows += renderSubstituteRow(sub);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<table class='table table-striped table-condensed' id='substitute-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Part" %}</th>
|
||||||
|
<th>{% trans "Description" %}</th>
|
||||||
|
<th><!-- Actions --></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class='alert alert-success alert-block'>
|
||||||
|
{% trans "Select and add a new variant item using the input below" %}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add a callback to remove a row from the table
|
||||||
|
function addRemoveCallback(modal, element) {
|
||||||
|
$(modal).find(element).click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
var pre = `
|
||||||
|
<div class='alert alert-block alert-warning'>
|
||||||
|
{% trans "Are you sure you wish to remove this substitute part link?" %}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
constructForm(`/api/bom/substitute/${pk}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
title: '{% trans "Remove Substitute Part" %}',
|
||||||
|
preFormContent: pre,
|
||||||
|
confirm: true,
|
||||||
|
onSuccess: function() {
|
||||||
|
$(modal).find(`#substitute-row-${pk}`).remove();
|
||||||
|
reloadParentTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
constructForm('{% url "api-bom-substitute-list" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
fields: {
|
||||||
|
bom_item: {
|
||||||
|
hidden: true,
|
||||||
|
value: bom_item_id,
|
||||||
|
},
|
||||||
|
part: {
|
||||||
|
required: false,
|
||||||
|
adjustFilters: function(query, opts) {
|
||||||
|
|
||||||
|
var subs = getSubstituteIdValues(opts.modal);
|
||||||
|
|
||||||
|
// Also exclude the "master" part (if provided)
|
||||||
|
if (options.sub_part) {
|
||||||
|
subs.push(options.sub_part);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subs.length > 0) {
|
||||||
|
query.exclude_id = subs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preFormContent: html,
|
||||||
|
cancelText: '{% trans "Close" %}',
|
||||||
|
submitText: '{% trans "Add Substitute" %}',
|
||||||
|
title: '{% trans "Edit BOM Item Substitutes" %}',
|
||||||
|
afterRender: function(fields, opts) {
|
||||||
|
addRemoveCallback(opts.modal, '.button-row-remove');
|
||||||
|
},
|
||||||
|
preventClose: true,
|
||||||
|
onSuccess: function(response, opts) {
|
||||||
|
|
||||||
|
// Clear the form
|
||||||
|
var field = {
|
||||||
|
type: 'related field',
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFieldValue('part', null, field, opts);
|
||||||
|
|
||||||
|
// Add the new substitute to the table
|
||||||
|
var row = renderSubstituteRow(response);
|
||||||
|
$(opts.modal).find('#substitute-table > tbody:last-child').append(row);
|
||||||
|
|
||||||
|
// Add a callback to the new button
|
||||||
|
addRemoveCallback(opts.modal, `#button-row-remove-${response.pk}`);
|
||||||
|
|
||||||
|
// Re-enable the "submit" button
|
||||||
|
$(opts.modal).find('#modal-form-submit').prop('disabled', false);
|
||||||
|
|
||||||
|
// Reload the parent BOM table
|
||||||
|
reloadParentTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadBomTable(table, options) {
|
function loadBomTable(table, options) {
|
||||||
/* Load a BOM table with some configurable options.
|
/* Load a BOM table with some configurable options.
|
||||||
*
|
*
|
||||||
@ -229,6 +397,14 @@ function loadBomTable(table, options) {
|
|||||||
|
|
||||||
html += makePartIcons(row.sub_part_detail);
|
html += makePartIcons(row.sub_part_detail);
|
||||||
|
|
||||||
|
if (row.substitutes && row.substitutes.length > 0) {
|
||||||
|
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.allow_variants) {
|
||||||
|
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
|
||||||
|
}
|
||||||
|
|
||||||
// Display an extra icon if this part is an assembly
|
// Display an extra icon if this part is an assembly
|
||||||
if (sub_part.assembly) {
|
if (sub_part.assembly) {
|
||||||
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
|
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
|
||||||
@ -301,6 +477,20 @@ function loadBomTable(table, options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'substitutes',
|
||||||
|
title: '{% trans "Substitutes" %}',
|
||||||
|
searchable: false,
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
if (row.substitutes && row.substitutes.length > 0) {
|
||||||
|
return row.substitutes.length;
|
||||||
|
} else {
|
||||||
|
return `-`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (show_pricing) {
|
if (show_pricing) {
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'purchase_price_range',
|
field: 'purchase_price_range',
|
||||||
@ -420,18 +610,17 @@ function loadBomTable(table, options) {
|
|||||||
|
|
||||||
if (row.part == options.parent_id) {
|
if (row.part == options.parent_id) {
|
||||||
|
|
||||||
var bValidate = `<button title='{% trans "Validate BOM Item" %}' class='bom-validate-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-check-circle icon-blue'/></button>`;
|
var bValidate = makeIconButton('fa-check-circle icon-green', 'bom-validate-button', row.pk, '{% trans "Validate BOM Item" %}');
|
||||||
|
|
||||||
var bValid = `<span title='{% trans "This line has been validated" %}' class='fas fa-check-double icon-green'/>`;
|
var bValid = `<span title='{% trans "This line has been validated" %}' class='fas fa-check-double icon-green'/>`;
|
||||||
|
|
||||||
var bEdit = `<button title='{% trans "Edit BOM Item" %}' class='bom-edit-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-edit'></span></button>`;
|
var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}');
|
||||||
|
|
||||||
var bDelt = `<button title='{% trans "Delete BOM Item" %}' class='bom-delete-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-trash-alt icon-red'></span></button>`;
|
var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
|
||||||
|
|
||||||
var html = `<div class='btn-group' role='group'>`;
|
var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
|
||||||
|
|
||||||
html += bEdit;
|
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
|
||||||
html += bDelt;
|
|
||||||
|
|
||||||
if (!row.validated) {
|
if (!row.validated) {
|
||||||
html += bValidate;
|
html += bValidate;
|
||||||
@ -439,6 +628,10 @@ function loadBomTable(table, options) {
|
|||||||
html += bValid;
|
html += bValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html += bEdit;
|
||||||
|
html += bSubs;
|
||||||
|
html += bDelt;
|
||||||
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
@ -490,6 +683,7 @@ function loadBomTable(table, options) {
|
|||||||
treeEnable: !options.editable,
|
treeEnable: !options.editable,
|
||||||
rootParentId: parent_id,
|
rootParentId: parent_id,
|
||||||
idField: 'pk',
|
idField: 'pk',
|
||||||
|
uniqueId: 'pk',
|
||||||
parentIdField: 'parentId',
|
parentIdField: 'parentId',
|
||||||
treeShowField: 'sub_part',
|
treeShowField: 'sub_part',
|
||||||
showColumns: true,
|
showColumns: true,
|
||||||
@ -566,19 +760,27 @@ function loadBomTable(table, options) {
|
|||||||
// In editing mode, attached editables to the appropriate table elements
|
// In editing mode, attached editables to the appropriate table elements
|
||||||
if (options.editable) {
|
if (options.editable) {
|
||||||
|
|
||||||
|
// Callback for "delete" button
|
||||||
table.on('click', '.bom-delete-button', function() {
|
table.on('click', '.bom-delete-button', function() {
|
||||||
|
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
{% trans "Are you sure you want to delete this BOM item?" %}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
constructForm(`/api/bom/${pk}/`, {
|
constructForm(`/api/bom/${pk}/`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete BOM Item" %}',
|
title: '{% trans "Delete BOM Item" %}',
|
||||||
|
preFormContent: html,
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
reloadBomTable(table);
|
reloadBomTable(table);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback for "edit" button
|
||||||
table.on('click', '.bom-edit-button', function() {
|
table.on('click', '.bom-edit-button', function() {
|
||||||
|
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
@ -595,6 +797,7 @@ function loadBomTable(table, options) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback for "validate" button
|
||||||
table.on('click', '.bom-validate-button', function() {
|
table.on('click', '.bom-validate-button', function() {
|
||||||
|
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
@ -613,5 +816,22 @@ function loadBomTable(table, options) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback for "substitutes" button
|
||||||
|
table.on('click', '.bom-substitutes-button', function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
var row = table.bootstrapTable('getRowByUniqueId', pk);
|
||||||
|
var subs = row.substitutes || [];
|
||||||
|
|
||||||
|
bomSubstitutesDialog(
|
||||||
|
pk,
|
||||||
|
subs,
|
||||||
|
{
|
||||||
|
table: table,
|
||||||
|
sub_part: row.sub_part,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -208,15 +208,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
|||||||
|
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
launchModalForm(
|
unallocateStock(buildId, {
|
||||||
`/build/${buildId}/unallocate/`,
|
|
||||||
{
|
|
||||||
success: reloadTable,
|
|
||||||
data: {
|
|
||||||
output: pk,
|
output: pk,
|
||||||
}
|
table: table,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
|
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
|
||||||
@ -236,6 +231,49 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unallocate stock against a particular build order
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* - output: pk value for a stock item "build output"
|
||||||
|
* - bom_item: pk value for a particular BOMItem (build item)
|
||||||
|
*/
|
||||||
|
function unallocateStock(build_id, options={}) {
|
||||||
|
|
||||||
|
var url = `/api/build/${build_id}/unallocate/`;
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<div class='alert alert-block alert-warning'>
|
||||||
|
{% trans "Are you sure you wish to unallocate stock items from this build?" %}
|
||||||
|
</dvi>
|
||||||
|
`;
|
||||||
|
|
||||||
|
constructForm(url, {
|
||||||
|
method: 'POST',
|
||||||
|
confirm: true,
|
||||||
|
preFormContent: html,
|
||||||
|
fields: {
|
||||||
|
output: {
|
||||||
|
hidden: true,
|
||||||
|
value: options.output,
|
||||||
|
},
|
||||||
|
bom_item: {
|
||||||
|
hidden: true,
|
||||||
|
value: options.bom_item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: '{% trans "Unallocate Stock Items" %}',
|
||||||
|
onSuccess: function(response, opts) {
|
||||||
|
if (options.table) {
|
||||||
|
// Reload the parent table
|
||||||
|
$(options.table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadBuildOrderAllocationTable(table, options={}) {
|
function loadBuildOrderAllocationTable(table, options={}) {
|
||||||
/**
|
/**
|
||||||
* Load a table showing all the BuildOrder allocations for a given part
|
* Load a table showing all the BuildOrder allocations for a given part
|
||||||
@ -348,6 +386,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
table = `#allocation-table-${outputId}`;
|
table = `#allocation-table-${outputId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
var filters = loadTableFilters('builditems');
|
||||||
|
|
||||||
|
var params = options.params || {};
|
||||||
|
|
||||||
|
for (var key in params) {
|
||||||
|
filters[key] = params[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFilterList('builditems', $(table), options.filterTarget || null);
|
||||||
|
|
||||||
// If an "output" is specified, then only "trackable" parts are allocated
|
// If an "output" is specified, then only "trackable" parts are allocated
|
||||||
// Otherwise, only "untrackable" parts are allowed
|
// Otherwise, only "untrackable" parts are allowed
|
||||||
var trackable = ! !output;
|
var trackable = ! !output;
|
||||||
@ -458,17 +507,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
|
|
||||||
// Callback for 'unallocate' button
|
// Callback for 'unallocate' button
|
||||||
$(table).find('.button-unallocate').click(function() {
|
$(table).find('.button-unallocate').click(function() {
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
launchModalForm(`/build/${buildId}/unallocate/`,
|
// Extract row data from the table
|
||||||
{
|
var idx = $(this).closest('tr').attr('data-index');
|
||||||
success: reloadTable,
|
var row = $(table).bootstrapTable('getData')[idx];
|
||||||
data: {
|
|
||||||
output: outputId,
|
unallocateStock(buildId, {
|
||||||
part: pk,
|
bom_item: row.pk,
|
||||||
}
|
output: outputId == 'untracked' ? null : outputId,
|
||||||
}
|
table: table,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -726,6 +774,14 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
|
|
||||||
html += makePartIcons(row.sub_part_detail);
|
html += makePartIcons(row.sub_part_detail);
|
||||||
|
|
||||||
|
if (row.substitutes && row.substitutes.length > 0) {
|
||||||
|
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitute parts available" %}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.allow_variants) {
|
||||||
|
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
|
||||||
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1021,12 +1077,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
|||||||
filters: {
|
filters: {
|
||||||
bom_item: bom_item.pk,
|
bom_item: bom_item.pk,
|
||||||
in_stock: true,
|
in_stock: true,
|
||||||
part_detail: false,
|
part_detail: true,
|
||||||
location_detail: true,
|
location_detail: true,
|
||||||
},
|
},
|
||||||
model: 'stockitem',
|
model: 'stockitem',
|
||||||
required: true,
|
required: true,
|
||||||
render_part_detail: false,
|
render_part_detail: true,
|
||||||
render_location_detail: true,
|
render_location_detail: true,
|
||||||
auto_fill: true,
|
auto_fill: true,
|
||||||
adjustFilters: function(filters) {
|
adjustFilters: function(filters) {
|
||||||
|
@ -283,10 +283,16 @@ function setupFilterList(tableKey, table, target) {
|
|||||||
|
|
||||||
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
|
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
|
||||||
|
|
||||||
|
// If there are no filters defined for this table, exit now
|
||||||
|
if (jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are filters currently "in use", add them in!
|
||||||
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
|
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
|
||||||
|
|
||||||
if (Object.keys(filters).length > 0) {
|
if (Object.keys(filters).length > 0) {
|
||||||
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-trash-alt'></span></button>`);
|
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-backspace icon-red'></span></button>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var key in filters) {
|
for (var key in filters) {
|
||||||
|
@ -1349,7 +1349,7 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
|
|
||||||
// Allow custom run-time filter augmentation
|
// Allow custom run-time filter augmentation
|
||||||
if ('adjustFilters' in field) {
|
if ('adjustFilters' in field) {
|
||||||
query = field.adjustFilters(query);
|
query = field.adjustFilters(query, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
|
@ -53,31 +53,16 @@ function renderStockItem(name, data, parameters, options) {
|
|||||||
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
|
||||||
|
|
||||||
var render_part_detail = true;
|
var render_part_detail = true;
|
||||||
|
|
||||||
if ('render_part_detail' in parameters) {
|
if ('render_part_detail' in parameters) {
|
||||||
render_part_detail = parameters['render_part_detail'];
|
render_part_detail = parameters['render_part_detail'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var part_detail = '';
|
||||||
|
|
||||||
if (render_part_detail) {
|
if (render_part_detail) {
|
||||||
html += `<img src='${image}' class='select2-thumbnail'>`;
|
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
|
||||||
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<span>';
|
|
||||||
|
|
||||||
if (data.serial && data.quantity == 1) {
|
|
||||||
html += `{% trans "Serial Number" %}: ${data.serial}`;
|
|
||||||
} else {
|
|
||||||
html += `{% trans "Quantity" %}: ${data.quantity}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</span>';
|
|
||||||
|
|
||||||
if (render_part_detail && data.part_detail.description) {
|
|
||||||
html += `<p><small>${data.part_detail.description}</small></p>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var render_stock_id = true;
|
var render_stock_id = true;
|
||||||
@ -86,8 +71,10 @@ function renderStockItem(name, data, parameters, options) {
|
|||||||
render_stock_id = parameters['render_stock_id'];
|
render_stock_id = parameters['render_stock_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stock_id = '';
|
||||||
|
|
||||||
if (render_stock_id) {
|
if (render_stock_id) {
|
||||||
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
stock_id = `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var render_location_detail = false;
|
var render_location_detail = false;
|
||||||
@ -96,10 +83,28 @@ function renderStockItem(name, data, parameters, options) {
|
|||||||
render_location_detail = parameters['render_location_detail'];
|
render_location_detail = parameters['render_location_detail'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var location_detail = '';
|
||||||
|
|
||||||
if (render_location_detail && data.location_detail) {
|
if (render_location_detail && data.location_detail) {
|
||||||
html += `<span> - ${data.location_detail.name}</span>`;
|
location_detail = ` - (<em>${data.location_detail.name}</em>)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stock_detail = '';
|
||||||
|
|
||||||
|
if (data.serial && data.quantity == 1) {
|
||||||
|
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;
|
||||||
|
} else if (data.quantity == 0) {
|
||||||
|
stock_detail = `<span class='label-form label-red'>{% trans "No Stock"% }</span>`;
|
||||||
|
} else {
|
||||||
|
stock_detail = `{% trans "Quantity" %}: ${data.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<span>
|
||||||
|
${part_detail}${stock_detail}${location_detail}${stock_id}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,21 +164,25 @@ function renderPart(name, data, parameters, options) {
|
|||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <i>${data.description}</i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var stock = '';
|
var extra = '';
|
||||||
|
|
||||||
// Display available part quantity
|
// Display available part quantity
|
||||||
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
||||||
if (data.in_stock == 0) {
|
if (data.in_stock == 0) {
|
||||||
stock = `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
|
extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
|
||||||
} else {
|
} else {
|
||||||
stock = `<span class='label-form label-green'>{% trans "In Stock" %}: ${data.in_stock}</span>`;
|
extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.active) {
|
||||||
|
extra += `<span class='label-form label-red'>{% trans "Inactive" %}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<span class='float-right'>
|
<span class='float-right'>
|
||||||
<small>
|
<small>
|
||||||
${stock}
|
${extra}
|
||||||
{% trans "Part ID" %}: ${data.pk}
|
{% trans "Part ID" %}: ${data.pk}
|
||||||
</small>
|
</small>
|
||||||
</span>`;
|
</span>`;
|
||||||
|
@ -592,7 +592,7 @@ function loadPartParameterTable(table, url, options) {
|
|||||||
filters[key] = params[key];
|
filters[key] = params[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupFilterLsit("#part-parameters", $(table));
|
// setupFilterList("#part-parameters", $(table));
|
||||||
|
|
||||||
$(table).inventreeTable({
|
$(table).inventreeTable({
|
||||||
url: url,
|
url: url,
|
||||||
@ -876,23 +876,7 @@ function loadPartTable(table, url, options={}) {
|
|||||||
switchable: false,
|
switchable: false,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var name = '';
|
var name = row.full_name;
|
||||||
|
|
||||||
if (row.IPN) {
|
|
||||||
name += row.IPN;
|
|
||||||
name += ' | ';
|
|
||||||
}
|
|
||||||
|
|
||||||
name += value;
|
|
||||||
|
|
||||||
if (row.revision) {
|
|
||||||
name += ' | ';
|
|
||||||
name += row.revision;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.is_template) {
|
|
||||||
name = '<i>' + name + '</i>';
|
|
||||||
}
|
|
||||||
|
|
||||||
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
|
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
|
||||||
|
|
||||||
|
@ -4,12 +4,6 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||||
{% settings_value 'BUILD_FUNCTION_ENABLE' as enable_build %}
|
|
||||||
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
|
|
||||||
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
|
|
||||||
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
|
|
||||||
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
|
|
||||||
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
|
|
||||||
|
|
||||||
<nav class="navbar navbar-xs navbar-default navbar-fixed-top ">
|
<nav class="navbar navbar-xs navbar-default navbar-fixed-top ">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@ -29,32 +23,28 @@
|
|||||||
{% if roles.part.view %}
|
{% if roles.part.view %}
|
||||||
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
|
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.stock.view and enable_stock %}
|
{% if roles.stock.view %}
|
||||||
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
|
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.build.view and enable_build %}
|
{% if roles.build.view %}
|
||||||
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
|
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.purchase_order.view and enable_buy %}
|
{% if roles.purchase_order.view %}
|
||||||
<li class='nav navbar-nav'>
|
<li class='nav navbar-nav'>
|
||||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
|
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
<li><a href="{% url 'supplier-index' %}"><span class='fas fa-building icon-header'></span>{% trans "Suppliers" %}</a></li>
|
<li><a href="{% url 'supplier-index' %}"><span class='fas fa-building icon-header'></span>{% trans "Suppliers" %}</a></li>
|
||||||
<li><a href="{% url 'manufacturer-index' %}"><span class='fas fa-industry icon-header'></span>{% trans "Manufacturers" %}</a></li>
|
<li><a href="{% url 'manufacturer-index' %}"><span class='fas fa-industry icon-header'></span>{% trans "Manufacturers" %}</a></li>
|
||||||
{% if enable_po %}
|
|
||||||
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
|
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.sales_order.view and enable_sell %}
|
{% if roles.sales_order.view %}
|
||||||
<li class='nav navbar-nav'>
|
<li class='nav navbar-nav'>
|
||||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
|
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
<li><a href="{% url 'customer-index' %}"><span class='fas fa-user-tie icon-header'></span>{% trans "Customers" %}</a>
|
<li><a href="{% url 'customer-index' %}"><span class='fas fa-user-tie icon-header'></span>{% trans "Customers" %}</a>
|
||||||
{% if enable_so %}
|
|
||||||
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
|
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -81,6 +81,7 @@ class RuleSet(models.Model):
|
|||||||
'part': [
|
'part': [
|
||||||
'part_part',
|
'part_part',
|
||||||
'part_bomitem',
|
'part_bomitem',
|
||||||
|
'part_bomitemsubstitute',
|
||||||
'part_partattachment',
|
'part_partattachment',
|
||||||
'part_partsellpricebreak',
|
'part_partsellpricebreak',
|
||||||
'part_partinternalpricebreak',
|
'part_partinternalpricebreak',
|
||||||
@ -110,6 +111,7 @@ class RuleSet(models.Model):
|
|||||||
'part_part',
|
'part_part',
|
||||||
'part_partcategory',
|
'part_partcategory',
|
||||||
'part_bomitem',
|
'part_bomitem',
|
||||||
|
'part_bomitemsubstitute',
|
||||||
'build_build',
|
'build_build',
|
||||||
'build_builditem',
|
'build_builditem',
|
||||||
'build_buildorderattachment',
|
'build_buildorderattachment',
|
||||||
|
@ -20,6 +20,7 @@ However, powerful business logic works in the background to ensure that stock tr
|
|||||||
# Docker
|
# Docker
|
||||||
|
|
||||||
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)
|
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)
|
||||||
|
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg)
|
||||||
|
|
||||||
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.
|
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.
|
||||||
|
|
||||||
|
@ -1,40 +1,39 @@
|
|||||||
# Django framework
|
# Please keep this list sorted
|
||||||
Django==3.2.5 # Django package
|
Django==3.2.5 # Django package
|
||||||
gunicorn>=20.1.0 # Gunicorn web server
|
certifi # Certifi is (most likely) installed through one of the requirements above
|
||||||
|
|
||||||
pillow==8.3.2 # Image manipulation
|
|
||||||
djangorestframework==3.12.4 # DRF framework
|
|
||||||
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
|
||||||
django-filter==2.4.0 # Extended filtering options
|
|
||||||
django-mptt==0.11.0 # Modified Preorder Tree Traversal
|
|
||||||
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
|
|
||||||
django-markdownx==3.0.1 # Markdown form fields
|
|
||||||
django-markdownify==0.8.0 # Markdown rendering
|
|
||||||
coreapi==2.3.0 # API documentation
|
coreapi==2.3.0 # API documentation
|
||||||
pygments==2.7.4 # Syntax highlighting
|
|
||||||
django-crispy-forms==1.11.2 # Form helpers
|
|
||||||
django-import-export==2.5.0 # Data import / export for admin interface
|
|
||||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
|
||||||
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
|
|
||||||
flake8==3.8.3 # PEP checking
|
|
||||||
pep8-naming==0.11.1 # PEP naming convention extension
|
|
||||||
coverage==5.3 # Unit test coverage
|
coverage==5.3 # Unit test coverage
|
||||||
coveralls==2.1.2 # Coveralls linking (for Travis)
|
coveralls==2.1.2 # Coveralls linking (for Travis)
|
||||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
cryptography==3.4.8 # Cryptography support
|
||||||
django-stdimage==5.1.1 # Advanced ImageField management
|
|
||||||
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
|
|
||||||
django-weasyprint==1.0.1 # django weasyprint integration
|
|
||||||
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
|
||||||
django-admin-shell==0.1.2 # Python shell for the admin interface
|
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||||
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
django-allauth==0.45.0 # SSO for external providers via OpenID
|
||||||
django-money==1.1 # Django app for currency management
|
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
|
||||||
certifi # Certifi is (most likely) installed through one of the requirements above
|
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||||
|
django-crispy-forms==1.11.2 # Form helpers
|
||||||
|
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
||||||
django-error-report==0.2.0 # Error report viewer for the admin interface
|
django-error-report==0.2.0 # Error report viewer for the admin interface
|
||||||
|
django-filter==2.4.0 # Extended filtering options
|
||||||
|
django-formtools==2.3 # Form wizard tools
|
||||||
|
django-import-export==2.5.0 # Data import / export for admin interface
|
||||||
|
django-markdownify==0.8.0 # Markdown rendering
|
||||||
|
django-markdownx==3.0.1 # Markdown form fields
|
||||||
|
django-money==1.1 # Django app for currency management
|
||||||
|
django-mptt==0.11.0 # Modified Preorder Tree Traversal
|
||||||
|
django-q==1.3.4 # Background task scheduling
|
||||||
|
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
|
||||||
|
django-stdimage==5.1.1 # Advanced ImageField management
|
||||||
django-test-migrations==1.1.0 # Unit testing for database migrations
|
django-test-migrations==1.1.0 # Unit testing for database migrations
|
||||||
|
django-weasyprint==1.0.1 # django weasyprint integration
|
||||||
|
djangorestframework==3.12.4 # DRF framework
|
||||||
|
flake8==3.8.3 # PEP checking
|
||||||
|
gunicorn>=20.1.0 # Gunicorn web server
|
||||||
|
inventree # Install the latest version of the InvenTree API python library
|
||||||
|
pep8-naming==0.11.1 # PEP naming convention extension
|
||||||
|
pillow==8.3.2 # Image manipulation
|
||||||
|
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||||
|
pygments==2.7.4 # Syntax highlighting
|
||||||
python-barcode[images]==0.13.1 # Barcode generator
|
python-barcode[images]==0.13.1 # Barcode generator
|
||||||
qrcode[pil]==6.1 # QR code generator
|
qrcode[pil]==6.1 # QR code generator
|
||||||
django-q==1.3.4 # Background task scheduling
|
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||||
django-formtools==2.3 # Form wizard tools
|
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||||
django-allauth==0.45.0 # SSO for external providers via OpenID
|
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
|
||||||
|
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
|
||||||
|
Loading…
Reference in New Issue
Block a user