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
|
||||
"""
|
||||
|
||||
for idx, field in enumerate(ordering):
|
||||
ordering_initial = ordering
|
||||
ordering = []
|
||||
|
||||
reverse = False
|
||||
for field in ordering_initial:
|
||||
|
||||
reverse = field.startswith('-')
|
||||
|
||||
if field.startswith('-'):
|
||||
if reverse:
|
||||
field = field[1:]
|
||||
reverse = True
|
||||
|
||||
# Are aliases defined for this field?
|
||||
if field in aliases:
|
||||
ordering[idx] = aliases[field]
|
||||
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:
|
||||
ordering[idx] = '-' + ordering[idx]
|
||||
a = '-' + a
|
||||
|
||||
ordering.append(a)
|
||||
|
||||
return ordering
|
||||
|
@ -4,10 +4,12 @@ Helper forms which subclass Django forms to provide additional functionality
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import logging
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
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.layout import Layout, Field
|
||||
@ -20,6 +22,8 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from part.models import PartCategory
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class HelperForm(forms.ModelForm):
|
||||
""" Provides simple integration of crispy_forms extension. """
|
||||
@ -223,11 +227,11 @@ class CustomSignupForm(SignupForm):
|
||||
# check for two mail fields
|
||||
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
|
||||
self.fields["email2"] = forms.EmailField(
|
||||
label=_("E-mail (again)"),
|
||||
label=_("Email (again)"),
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"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
|
||||
"""
|
||||
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 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):
|
||||
"""
|
||||
@ -268,7 +284,7 @@ class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter):
|
||||
"""
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
"""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 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
|
||||
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
@ -43,6 +44,48 @@ def rename_attachment(instance, 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):
|
||||
""" Provides an abstracted class for managing file attachments.
|
||||
|
||||
|
@ -396,39 +396,6 @@ Q_CLUSTER = {
|
||||
'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.
|
||||
|
||||
@ -495,7 +462,47 @@ logger.info(f"DB_ENGINE: {db_engine}")
|
||||
logger.info(f"DB_NAME: {db_name}")
|
||||
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 = {
|
||||
'default': {
|
||||
@ -695,6 +702,37 @@ ACCOUNT_FORMS = {
|
||||
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
||||
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
|
||||
PLUGIN_URL = 'plugin'
|
||||
|
||||
|
@ -455,6 +455,10 @@
|
||||
-webkit-opacity: 10%;
|
||||
}
|
||||
|
||||
.table-condensed {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
/* grid display for part images */
|
||||
|
||||
.table-img-grid tr {
|
||||
|
@ -5,6 +5,7 @@ Custom field validators for InvenTree
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
@ -156,3 +157,33 @@ def validate_overage(value):
|
||||
raise ValidationError(
|
||||
_("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):
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
]
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
'title',
|
||||
|
@ -17,11 +17,12 @@ from django_filters import rest_framework as rest_filters
|
||||
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||
from .serializers import BuildAllocationSerializer
|
||||
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
|
||||
|
||||
|
||||
class BuildFilter(rest_filters.FilterSet):
|
||||
@ -68,7 +69,7 @@ class BuildList(generics.ListCreateAPIView):
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
@ -83,6 +84,10 @@ class BuildList(generics.ListCreateAPIView):
|
||||
'responsible',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
}
|
||||
|
||||
search_fields = [
|
||||
'reference',
|
||||
'part__name',
|
||||
@ -184,6 +189,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
||||
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):
|
||||
"""
|
||||
API endpoint to allocate stock items to a build order
|
||||
@ -349,6 +390,7 @@ build_api_urls = [
|
||||
# Build Detail
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
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'),
|
||||
])),
|
||||
|
||||
|
@ -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):
|
||||
"""
|
||||
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.helpers import increment, getSetting, normalize, MakeBarcode
|
||||
from InvenTree.validators import validate_build_order_reference
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
|
||||
import common.models
|
||||
|
||||
@ -69,7 +69,7 @@ def get_next_build_number():
|
||||
return reference
|
||||
|
||||
|
||||
class Build(MPTTModel):
|
||||
class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||
|
||||
Attributes:
|
||||
@ -108,6 +108,8 @@ class Build(MPTTModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.rebuild_reference_field()
|
||||
|
||||
try:
|
||||
super().save(*args, **kwargs)
|
||||
except InvalidMove:
|
||||
@ -587,9 +589,13 @@ class Build(MPTTModel):
|
||||
self.save()
|
||||
|
||||
@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(
|
||||
@ -597,34 +603,8 @@ class Build(MPTTModel):
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if part:
|
||||
allocations = allocations.filter(stock_item__part=part)
|
||||
|
||||
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)
|
||||
if bom_item:
|
||||
allocations = allocations.filter(bom_item=bom_item)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@ -720,7 +700,7 @@ class Build(MPTTModel):
|
||||
raise ValidationError(_("Build output does not match Build Order"))
|
||||
|
||||
# Unallocate all build items against the output
|
||||
self.unallocateOutput(output)
|
||||
self.unallocateStock(output=output)
|
||||
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
@ -1153,16 +1133,12 @@ class BuildItem(models.Model):
|
||||
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
|
||||
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:
|
||||
|
||||
# Check that the sub_part points to the stock_item (either directly or via a variant)
|
||||
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
|
||||
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
|
||||
|
||||
# If the existing BomItem is *not* valid, try to find a match
|
||||
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):
|
||||
"""
|
||||
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" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-build-items'>
|
||||
<div class='filter-list' id='filter-list-builditems'>
|
||||
<!-- Empty div for table filters-->
|
||||
</div>
|
||||
</div>
|
||||
@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() {
|
||||
});
|
||||
|
||||
$('#btn-unallocate').on('click', function() {
|
||||
launchModalForm(
|
||||
"{% url 'build-unallocate' build.id %}",
|
||||
{
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
unallocateStock({{ build.id }}, {
|
||||
table: '#allocation-table-untracked',
|
||||
});
|
||||
});
|
||||
|
||||
$('#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)
|
||||
|
||||
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):
|
||||
# Perform some basic tests before we start the ball rolling
|
||||
|
||||
@ -250,7 +270,7 @@ class BuildTest(TestCase):
|
||||
|
||||
self.assertEqual(len(unallocated), 1)
|
||||
|
||||
self.build.unallocateUntracked()
|
||||
self.build.unallocateStock()
|
||||
|
||||
unallocated = self.build.unallocatedParts(None)
|
||||
|
||||
|
@ -323,22 +323,3 @@ class TestBuildViews(TestCase):
|
||||
|
||||
b = Build.objects.get(pk=1)
|
||||
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'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||
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'^.*$', 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.forms import HiddenInput
|
||||
|
||||
from part.models import Part
|
||||
from .models import Build
|
||||
from . import forms
|
||||
from stock.models import StockLocation, StockItem
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||
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
|
||||
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
View to mark the build as complete.
|
||||
|
@ -12,7 +12,7 @@ import math
|
||||
import uuid
|
||||
|
||||
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.conf import settings
|
||||
|
||||
@ -26,6 +26,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.fields
|
||||
import InvenTree.validators
|
||||
|
||||
import logging
|
||||
|
||||
@ -182,12 +183,9 @@ class BaseInvenTreeSetting(models.Model):
|
||||
else:
|
||||
choices = None
|
||||
|
||||
"""
|
||||
TODO:
|
||||
if type(choices) is function:
|
||||
if callable(choices):
|
||||
# Evaluate the function (we expect it will return a list of tuples...)
|
||||
return choices()
|
||||
"""
|
||||
|
||||
return choices
|
||||
|
||||
@ -479,6 +477,11 @@ class BaseInvenTreeSetting(models.Model):
|
||||
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):
|
||||
"""
|
||||
An InvenTreeSetting object is a key:value pair used for storing
|
||||
@ -703,6 +706,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Debug Mode'),
|
||||
'description': _('Generate reports in debug mode (HTML output)'),
|
||||
@ -794,43 +805,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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_ENABLE_PWD_FORGOT': {
|
||||
'name': _('Enable password forgot'),
|
||||
@ -851,7 +825,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
'LOGIN_MAIL_REQUIRED': {
|
||||
'name': _('E-Mail required'),
|
||||
'name': _('Email required'),
|
||||
'description': _('Require user to supply mail on signup'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
@ -874,7 +848,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
'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:
|
||||
|
@ -136,3 +136,24 @@ class SettingsViewTest(TestCase):
|
||||
for value in [False, 'False']:
|
||||
self.post(url, {'value': value}, valid=True)
|
||||
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 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'>
|
||||
<li class='list-group-item'>
|
||||
<a href='#' id='company-menu-toggle'>
|
||||
@ -32,7 +28,6 @@
|
||||
{% endif %}
|
||||
|
||||
{% if company.is_manufacturer or company.is_supplier %}
|
||||
{% if enable_stock %}
|
||||
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
||||
<a href='#' id='select-company-stock' class='nav-toggle'>
|
||||
<span class='fas fa-boxes sidebar-icon'></span>
|
||||
@ -40,9 +35,8 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if company.is_supplier and enable_po %}
|
||||
{% if company.is_supplier %}
|
||||
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||
<span class='fas fa-shopping-cart sidebar-icon'></span>
|
||||
@ -51,7 +45,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if company.is_customer and enable_so %}
|
||||
{% if company.is_customer %}
|
||||
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
||||
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
||||
<span class='fas fa-truck sidebar-icon'></span>
|
||||
|
@ -20,6 +20,10 @@ class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
|
||||
class PurchaseOrderAdmin(ImportExportModelAdmin):
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
]
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
'supplier',
|
||||
@ -41,6 +45,10 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
|
||||
|
||||
class SalesOrderAdmin(ImportExportModelAdmin):
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
]
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
'customer',
|
||||
|
@ -152,9 +152,13 @@ class POList(generics.ListCreateAPIView):
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
}
|
||||
|
||||
filter_fields = [
|
||||
'supplier',
|
||||
]
|
||||
@ -504,9 +508,13 @@ class SOList(generics.ListCreateAPIView):
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
}
|
||||
|
||||
filter_fields = [
|
||||
'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.helpers import decimal2string, increment, getSetting
|
||||
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():
|
||||
@ -89,7 +89,7 @@ def get_next_so_number():
|
||||
return reference
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
class Order(ReferenceIndexingMixin):
|
||||
""" Abstract model for an order.
|
||||
|
||||
Instances of this class:
|
||||
@ -147,6 +147,9 @@ class Order(models.Model):
|
||||
return new_ref
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.rebuild_reference_field()
|
||||
|
||||
if not self.creation_date:
|
||||
self.creation_date = datetime.now().date()
|
||||
|
||||
@ -531,6 +534,12 @@ class SalesOrder(Order):
|
||||
|
||||
return queryset
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.rebuild_reference_field()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
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 .models import Part, PartCategory, BomItem
|
||||
from .models import Part, PartCategory
|
||||
from .models import BomItem, BomItemSubstitute
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
@ -813,6 +814,27 @@ class PartList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
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_tree = params.get('exclude_tree', None)
|
||||
|
||||
@ -1078,11 +1100,23 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
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)
|
||||
|
||||
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)
|
||||
else:
|
||||
return Response(data)
|
||||
@ -1102,7 +1136,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
try:
|
||||
# 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:
|
||||
pass
|
||||
|
||||
@ -1147,13 +1181,19 @@ class BomList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
include_pricing = str2bool(params.get('include_pricing', True))
|
||||
|
||||
if include_pricing:
|
||||
if self.include_pricing():
|
||||
queryset = self.annotate_pricing(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):
|
||||
"""
|
||||
Add part pricing information to the queryset
|
||||
@ -1262,6 +1302,35 @@ class BomItemValidate(generics.UpdateAPIView):
|
||||
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 = [
|
||||
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
|
||||
|
||||
@ -1314,6 +1383,16 @@ part_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
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
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.dispatch import receiver
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from django_cleanup import cleanup
|
||||
@ -38,6 +40,7 @@ from datetime import datetime
|
||||
import hashlib
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from common.settings import currency_code_default
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
from InvenTree import helpers
|
||||
from InvenTree import validators
|
||||
@ -555,7 +558,9 @@ class Part(MPTTModel):
|
||||
|
||||
@property
|
||||
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)
|
||||
- Part name
|
||||
@ -564,17 +569,31 @@ class Part(MPTTModel):
|
||||
Elements are joined by the | character
|
||||
"""
|
||||
|
||||
elements = []
|
||||
full_name_pattern = InvenTreeSetting.get_setting('PART_NAME_FORMAT')
|
||||
|
||||
if self.IPN:
|
||||
elements.append(self.IPN)
|
||||
try:
|
||||
context = {'part': self}
|
||||
template_string = Template(full_name_pattern)
|
||||
full_name = template_string.render(context)
|
||||
|
||||
elements.append(self.name)
|
||||
return full_name
|
||||
|
||||
if self.revision:
|
||||
elements.append(self.revision)
|
||||
except AttributeError as attr_err:
|
||||
|
||||
return ' | '.join(elements)
|
||||
logger.warning(f"exception while trying to create full name for part {self.name}", attr_err)
|
||||
|
||||
# Fallback to default format
|
||||
elements = []
|
||||
|
||||
if self.IPN:
|
||||
elements.append(self.IPN)
|
||||
|
||||
elements.append(self.name)
|
||||
|
||||
if self.revision:
|
||||
elements.append(self.revision)
|
||||
|
||||
return ' | '.join(elements)
|
||||
|
||||
def set_category(self, category):
|
||||
|
||||
@ -2333,22 +2352,48 @@ class BomItem(models.Model):
|
||||
def get_api_url():
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
"""
|
||||
|
||||
# Target part
|
||||
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)
|
||||
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@ -2613,6 +2658,66 @@ class BomItem(models.Model):
|
||||
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):
|
||||
""" 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 stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
from .models import (BomItem, BomItemSubstitute,
|
||||
Part, PartAttachment, PartCategory,
|
||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||
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):
|
||||
""" Serializer for BomItem object """
|
||||
"""
|
||||
Serializer for BomItem object
|
||||
"""
|
||||
|
||||
price_range = serializers.CharField(read_only=True)
|
||||
|
||||
@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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)
|
||||
|
||||
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
|
||||
@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'reference',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
'substitutes',
|
||||
'price_range',
|
||||
'validated',
|
||||
]
|
||||
|
@ -1,11 +1,12 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% if roles.part.change != True and editing_enabled %}
|
||||
{% if not roles.part.change %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans "You do not have permission to edit the BOM." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.bom_checked_date %}
|
||||
{% if part.is_bom_valid %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
@ -23,42 +24,38 @@
|
||||
|
||||
<div id='bom-button-toolbar'>
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
{% if editing_enabled %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
|
||||
<span class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
|
||||
<span class='fas fa-file-upload'></span>
|
||||
</button>
|
||||
{% if part.variant_of %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
|
||||
<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 %}
|
||||
<!-- Export menu -->
|
||||
<div class='btn-group'>
|
||||
<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>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
|
||||
<li><a href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if roles.part.change %}
|
||||
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
|
||||
<span class='fas fa-edit'></span>
|
||||
</button>
|
||||
{% if part.is_bom_valid == False %}
|
||||
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
|
||||
<span class='fas fa-clipboard-check'></span>
|
||||
<!-- Action menu -->
|
||||
<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>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
|
||||
{% 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>
|
||||
{% 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'>
|
||||
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
||||
</div>
|
||||
@ -67,4 +64,3 @@
|
||||
|
||||
<table class='table table-bom table-condensed' data-toolbar="#bom-button-toolbar" id='bom-table'>
|
||||
</table>
|
||||
{% endif %}
|
@ -473,7 +473,11 @@
|
||||
onPanelLoad("bom", function() {
|
||||
// Load the BOM table data
|
||||
loadBomTable($("#bom-table"), {
|
||||
editable: {{ editing_enabled }},
|
||||
{% if roles.part.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
bom_url: "{% url 'api-bom-list' %}",
|
||||
part_url: "{% url 'api-part-list' %}",
|
||||
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() {
|
||||
|
||||
// Get a list of the selected BOM items
|
||||
@ -559,8 +558,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
{% else %}
|
||||
|
||||
$("#validate-bom").click(function() {
|
||||
launchModalForm(
|
||||
"{% 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 () {
|
||||
launchModalForm("{% url 'bom-export' part.id %}",
|
||||
{
|
||||
@ -584,8 +577,6 @@
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
$("#print-bom-report").click(function() {
|
||||
printBomReports([{{ part.pk }}]);
|
||||
});
|
||||
@ -629,10 +620,9 @@
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Load the BOM table data in the pricing view
|
||||
loadBomTable($("#bom-pricing-table"), {
|
||||
editable: {{ editing_enabled }},
|
||||
editable: false,
|
||||
bom_url: "{% url 'api-bom-list' %}",
|
||||
part_url: "{% url 'api-part-list' %}",
|
||||
parent_id: {{ part.id }} ,
|
||||
|
@ -4,12 +4,6 @@
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% 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'>
|
||||
<li class='list-group-item'>
|
||||
@ -31,14 +25,12 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if enable_stock %}
|
||||
<li class='list-group-item' title='{% trans "Stock Items" %}'>
|
||||
<a href='#' id='select-part-stock' class='nav-toggle'>
|
||||
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
|
||||
{% trans "Stock" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if part.assembly %}
|
||||
<li class='list-group-item' title='{% trans "Bill of Materials" %}'>
|
||||
<a href='#' id='select-bom' class='nav-toggle'>
|
||||
@ -46,7 +38,7 @@
|
||||
{% trans "Bill of Materials" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if roles.build.view and enable_build %}
|
||||
{% if roles.build.view %}
|
||||
<li class='list-group-item ' title='{% trans "Build Orders" %}'>
|
||||
<a href='#' id='select-build-orders' class='nav-toggle'>
|
||||
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
|
||||
@ -63,22 +55,19 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if enable_buy or enable_sell %}
|
||||
<li class='list-group-item' title='{% trans "Pricing Information" %}'>
|
||||
<a href='#' id='select-pricing' class='nav-toggle'>
|
||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||
{% trans "Prices" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if part.purchaseable and roles.purchase_order.view and enable_buy %}
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
<li class='list-group-item' title='{% trans "Suppliers" %}'>
|
||||
<a href='#' id='select-suppliers' class='nav-toggle'>
|
||||
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
|
||||
{% trans "Suppliers" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if enable_po %}
|
||||
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
|
||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
|
||||
@ -86,8 +75,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if part.salable and roles.sales_order.view and enable_sell and enable_so %}
|
||||
{% if part.salable and roles.sales_order.view %}
|
||||
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
|
||||
<a href='#' id='select-sales-orders' class='nav-toggle'>
|
||||
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span>
|
||||
|
@ -10,11 +10,6 @@
|
||||
|
||||
{% 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">
|
||||
<!-- Default panel contents -->
|
||||
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
|
||||
@ -85,12 +80,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if part.active %}
|
||||
{% if enable_buy or enable_sell %}
|
||||
<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'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if roles.stock.change and enable_stock %}
|
||||
{% if roles.stock.change %}
|
||||
<div class='btn-group'>
|
||||
<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>
|
||||
@ -112,13 +105,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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" %}'>
|
||||
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!-- Part actions -->
|
||||
{% if roles.part.add or roles.part.change or roles.part.delete %}
|
||||
<div class='btn-group'>
|
||||
|
@ -1,4 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import PIL
|
||||
|
||||
@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
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 common.models import InvenTreeSetting
|
||||
|
||||
@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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):
|
||||
|
||||
url = reverse('api-part-test-template-list')
|
||||
@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""
|
||||
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
|
||||
import django.core.exceptions as django_exceptions
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Part, BomItem
|
||||
from .models import Part, BomItem, BomItemSubstitute
|
||||
|
||||
|
||||
class BomItemTest(TestCase):
|
||||
@ -130,3 +135,67 @@ class BomItemTest(TestCase):
|
||||
self.bob.get_bom_price_range(1, internal=True),
|
||||
(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['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):
|
||||
"""
|
||||
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
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Provide extra context data to template
|
||||
|
||||
- If '?editing=True', set 'editing_enabled' context variable
|
||||
"""
|
||||
Provide extra context data to template
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
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)
|
||||
context.update(**ctx)
|
||||
|
||||
|
@ -72,7 +72,9 @@
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<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'>
|
||||
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if roles.stock.change %}
|
||||
|
@ -13,8 +13,6 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<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_REGEX" %}
|
||||
</tbody>
|
||||
|
@ -14,7 +14,6 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<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_PWD_FORGOT" 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 colspan='4'></td>
|
||||
</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_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="SIGNUP_GROUP" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
{% 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_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_BOM" icon="fa-dollar-sign" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
||||
|
@ -11,9 +11,6 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<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" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -12,9 +12,6 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<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" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -12,8 +12,6 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<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_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
||||
|
@ -39,12 +39,12 @@
|
||||
</table>
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "E-Mail" %}</h4>
|
||||
<h4>{% trans "Email" %}</h4>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% 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">
|
||||
{% csrf_token %}
|
||||
@ -78,19 +78,19 @@
|
||||
|
||||
{% else %}
|
||||
<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>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if can_add_email %}
|
||||
<br>
|
||||
<h4>{% trans "Add E-mail Address" %}</h4>
|
||||
<h4>{% trans "Add Email Address" %}</h4>
|
||||
|
||||
<form method="post" action="{% url 'account_email' %}" class="add_email">
|
||||
{% csrf_token %}
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
<br>
|
||||
@ -220,7 +220,7 @@
|
||||
|
||||
{% block js_ready %}
|
||||
(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');
|
||||
if (actions.length) {
|
||||
actions[0].addEventListener("click", function(e) {
|
||||
|
@ -3,17 +3,17 @@
|
||||
{% load i18n %}
|
||||
{% load account %}
|
||||
|
||||
{% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %}
|
||||
{% block head_title %}{% trans "Confirm Email Address" %}{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Confirm E-mail Address" %}</h1>
|
||||
<h1>{% trans "Confirm Email Address" %}</h1>
|
||||
|
||||
{% if confirmation %}
|
||||
|
||||
{% 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 %}">
|
||||
{% csrf_token %}
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
{% 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 %}
|
||||
|
||||
|
@ -15,7 +15,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
{% 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) {
|
||||
/* Load a BOM table with some configurable options.
|
||||
*
|
||||
@ -229,6 +397,14 @@ function loadBomTable(table, options) {
|
||||
|
||||
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
|
||||
if (sub_part.assembly) {
|
||||
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
|
||||
@ -300,6 +476,20 @@ function loadBomTable(table, options) {
|
||||
return renderLink(text, url);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
cols.push({
|
||||
@ -420,18 +610,17 @@ function loadBomTable(table, options) {
|
||||
|
||||
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 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;
|
||||
html += bDelt;
|
||||
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
|
||||
|
||||
if (!row.validated) {
|
||||
html += bValidate;
|
||||
@ -439,6 +628,10 @@ function loadBomTable(table, options) {
|
||||
html += bValid;
|
||||
}
|
||||
|
||||
html += bEdit;
|
||||
html += bSubs;
|
||||
html += bDelt;
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
@ -490,6 +683,7 @@ function loadBomTable(table, options) {
|
||||
treeEnable: !options.editable,
|
||||
rootParentId: parent_id,
|
||||
idField: 'pk',
|
||||
uniqueId: 'pk',
|
||||
parentIdField: 'parentId',
|
||||
treeShowField: 'sub_part',
|
||||
showColumns: true,
|
||||
@ -566,19 +760,27 @@ function loadBomTable(table, options) {
|
||||
// In editing mode, attached editables to the appropriate table elements
|
||||
if (options.editable) {
|
||||
|
||||
// Callback for "delete" button
|
||||
table.on('click', '.bom-delete-button', function() {
|
||||
|
||||
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}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete BOM Item" %}',
|
||||
preFormContent: html,
|
||||
onSuccess: function() {
|
||||
reloadBomTable(table);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for "edit" button
|
||||
table.on('click', '.bom-edit-button', function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
@ -595,6 +797,7 @@ function loadBomTable(table, options) {
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for "validate" button
|
||||
table.on('click', '.bom-validate-button', function() {
|
||||
|
||||
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');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/unallocate/`,
|
||||
{
|
||||
success: reloadTable,
|
||||
data: {
|
||||
output: pk,
|
||||
}
|
||||
}
|
||||
);
|
||||
unallocateStock(buildId, {
|
||||
output: pk,
|
||||
table: table,
|
||||
});
|
||||
});
|
||||
|
||||
$(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={}) {
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Otherwise, only "untrackable" parts are allowed
|
||||
var trackable = ! !output;
|
||||
@ -458,17 +507,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
// Callback for 'unallocate' button
|
||||
$(table).find('.button-unallocate').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(`/build/${buildId}/unallocate/`,
|
||||
{
|
||||
success: reloadTable,
|
||||
data: {
|
||||
output: outputId,
|
||||
part: pk,
|
||||
}
|
||||
}
|
||||
);
|
||||
// Extract row data from the table
|
||||
var idx = $(this).closest('tr').attr('data-index');
|
||||
var row = $(table).bootstrapTable('getData')[idx];
|
||||
|
||||
unallocateStock(buildId, {
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
@ -1021,12 +1077,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
filters: {
|
||||
bom_item: bom_item.pk,
|
||||
in_stock: true,
|
||||
part_detail: false,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
model: 'stockitem',
|
||||
required: true,
|
||||
render_part_detail: false,
|
||||
render_part_detail: true,
|
||||
render_location_detail: true,
|
||||
auto_fill: true,
|
||||
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>`);
|
||||
|
||||
// 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>`);
|
||||
|
||||
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) {
|
||||
|
@ -1349,7 +1349,7 @@ function initializeRelatedField(field, fields, options) {
|
||||
|
||||
// Allow custom run-time filter augmentation
|
||||
if ('adjustFilters' in field) {
|
||||
query = field.adjustFilters(query);
|
||||
query = field.adjustFilters(query, options);
|
||||
}
|
||||
|
||||
return query;
|
||||
|
@ -52,8 +52,6 @@ function renderStockItem(name, data, parameters, options) {
|
||||
if (data.part_detail) {
|
||||
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
var render_part_detail = true;
|
||||
|
||||
@ -61,23 +59,10 @@ function renderStockItem(name, data, parameters, options) {
|
||||
render_part_detail = parameters['render_part_detail'];
|
||||
}
|
||||
|
||||
var part_detail = '';
|
||||
|
||||
if (render_part_detail) {
|
||||
html += `<img src='${image}' class='select2-thumbnail'>`;
|
||||
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>`;
|
||||
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
|
||||
}
|
||||
|
||||
var render_stock_id = true;
|
||||
@ -86,8 +71,10 @@ function renderStockItem(name, data, parameters, options) {
|
||||
render_stock_id = parameters['render_stock_id'];
|
||||
}
|
||||
|
||||
var 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;
|
||||
@ -96,10 +83,28 @@ function renderStockItem(name, data, parameters, options) {
|
||||
render_location_detail = parameters['render_location_detail'];
|
||||
}
|
||||
|
||||
var 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;
|
||||
}
|
||||
|
||||
@ -159,21 +164,25 @@ function renderPart(name, data, parameters, options) {
|
||||
html += ` - <i>${data.description}</i>`;
|
||||
}
|
||||
|
||||
var stock = '';
|
||||
var extra = '';
|
||||
|
||||
// Display available part quantity
|
||||
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
||||
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 {
|
||||
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 += `
|
||||
<span class='float-right'>
|
||||
<small>
|
||||
${stock}
|
||||
${extra}
|
||||
{% trans "Part ID" %}: ${data.pk}
|
||||
</small>
|
||||
</span>`;
|
||||
|
@ -592,7 +592,7 @@ function loadPartParameterTable(table, url, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
// setupFilterLsit("#part-parameters", $(table));
|
||||
// setupFilterList("#part-parameters", $(table));
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: url,
|
||||
@ -876,23 +876,7 @@ function loadPartTable(table, url, options={}) {
|
||||
switchable: false,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var 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 name = row.full_name;
|
||||
|
||||
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
|
||||
|
||||
|
@ -4,12 +4,6 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% 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 ">
|
||||
<div class="container-fluid">
|
||||
@ -29,32 +23,28 @@
|
||||
{% if roles.part.view %}
|
||||
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.view and enable_buy %}
|
||||
{% if roles.purchase_order.view %}
|
||||
<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>
|
||||
<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 '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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if roles.sales_order.view and enable_sell %}
|
||||
{% if roles.sales_order.view %}
|
||||
<li class='nav navbar-nav'>
|
||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
|
||||
<ul class='dropdown-menu'>
|
||||
<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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
@ -81,6 +81,7 @@ class RuleSet(models.Model):
|
||||
'part': [
|
||||
'part_part',
|
||||
'part_bomitem',
|
||||
'part_bomitemsubstitute',
|
||||
'part_partattachment',
|
||||
'part_partsellpricebreak',
|
||||
'part_partinternalpricebreak',
|
||||
@ -110,6 +111,7 @@ class RuleSet(models.Model):
|
||||
'part_part',
|
||||
'part_partcategory',
|
||||
'part_bomitem',
|
||||
'part_bomitemsubstitute',
|
||||
'build_build',
|
||||
'build_builditem',
|
||||
'build_buildorderattachment',
|
||||
|
@ -20,6 +20,7 @@ However, powerful business logic works in the background to ensure that stock tr
|
||||
# Docker
|
||||
|
||||
[![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.
|
||||
|
||||
|
@ -1,40 +1,39 @@
|
||||
# Django framework
|
||||
# Please keep this list sorted
|
||||
Django==3.2.5 # Django package
|
||||
gunicorn>=20.1.0 # Gunicorn web server
|
||||
|
||||
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
|
||||
certifi # Certifi is (most likely) installed through one of the requirements above
|
||||
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
|
||||
coveralls==2.1.2 # Coveralls linking (for Travis)
|
||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||
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
|
||||
cryptography==3.4.8 # Cryptography support
|
||||
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||
django-money==1.1 # Django app for currency management
|
||||
certifi # Certifi is (most likely) installed through one of the requirements above
|
||||
django-allauth==0.45.0 # SSO for external providers via OpenID
|
||||
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
|
||||
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-test-migrations==1.1.0 # Unit testing for database migrations
|
||||
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-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
|
||||
qrcode[pil]==6.1 # QR code generator
|
||||
django-q==1.3.4 # Background task scheduling
|
||||
django-formtools==2.3 # Form wizard tools
|
||||
django-allauth==0.45.0 # SSO for external providers via OpenID
|
||||
|
||||
inventree # Install the latest version of the InvenTree API python library
|
||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
|
||||
|
Loading…
Reference in New Issue
Block a user