mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge branch 'inventree:master' into trans-improv
This commit is contained in:
		| @@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, instance=None, data=empty, **kwargs): | ||||
|  | ||||
|         # self.instance = instance | ||||
|         """ | ||||
|         Custom __init__ routine to ensure that *default* values (as specified in the ORM) | ||||
|         are used by the DRF serializers, *if* the values are not provided by the user. | ||||
|         """ | ||||
|  | ||||
|         # If instance is None, we are creating a new instance | ||||
|         if instance is None and data is not empty: | ||||
| @@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): | ||||
|         try: | ||||
|             instance.full_clean() | ||||
|         except (ValidationError, DjangoValidationError) as exc: | ||||
|             raise ValidationError(detail=serializers.as_serializer_error(exc)) | ||||
|  | ||||
|             data = exc.message_dict | ||||
|  | ||||
|             # Change '__all__' key (django style) to 'non_field_errors' (DRF style) | ||||
|             if '__all__' in data: | ||||
|                 data['non_field_errors'] = data['__all__'] | ||||
|                 del data['__all__'] | ||||
|  | ||||
|             raise ValidationError(data) | ||||
|  | ||||
|         return data | ||||
|  | ||||
|   | ||||
| @@ -111,8 +111,8 @@ src="{% static 'img/blank_image.png' %}" | ||||
|             <li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li> | ||||
|             {% endif %} | ||||
|             {% if build.status == BuildStatus.CANCELLED and roles.build.delete %} | ||||
|             <li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a> | ||||
|                 {% endif %} | ||||
|             <li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build" %}</a> | ||||
|             {% endif %} | ||||
|         </ul> | ||||
|     </div> | ||||
|     {% endif %} | ||||
|   | ||||
| @@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): | ||||
|         'PART_PURCHASEABLE': { | ||||
|             'name': _('Purchaseable'), | ||||
|             'description': _('Parts are purchaseable by default'), | ||||
|             'default': False, | ||||
|             'default': True, | ||||
|             'validator': bool, | ||||
|         }, | ||||
|  | ||||
| @@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): | ||||
|             'validator': bool, | ||||
|         }, | ||||
|  | ||||
|         # TODO: Remove this setting in future, new API forms make this not useful | ||||
|         'PART_SHOW_QUANTITY_IN_FORMS': { | ||||
|             'name': _('Show Quantity in Forms'), | ||||
|             'description': _('Display available part quantity in some forms'), | ||||
|   | ||||
| @@ -23,6 +23,7 @@ from djmoney.money import Money | ||||
| from djmoney.contrib.exchange.models import convert_money | ||||
| from djmoney.contrib.exchange.exceptions import MissingRate | ||||
|  | ||||
| from decimal import Decimal | ||||
|  | ||||
| from .models import Part, PartCategory, BomItem | ||||
| from .models import PartParameter, PartParameterTemplate | ||||
| @@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate | ||||
| from .models import PartSellPriceBreak, PartInternalPriceBreak | ||||
| from .models import PartCategoryParameterTemplate | ||||
|  | ||||
| from stock.models import StockItem | ||||
| from common.models import InvenTreeSetting | ||||
| from build.models import Build | ||||
|  | ||||
| @@ -338,9 +340,7 @@ class PartThumbs(generics.ListAPIView): | ||||
|         - Images may be used for multiple parts! | ||||
|         """ | ||||
|  | ||||
|         queryset = self.get_queryset() | ||||
|  | ||||
|         # TODO - We should return the thumbnails here, not the full image! | ||||
|         queryset = self.filter_queryset(self.get_queryset()) | ||||
|  | ||||
|         # Return the most popular parts first | ||||
|         data = queryset.values( | ||||
| @@ -349,6 +349,19 @@ class PartThumbs(generics.ListAPIView): | ||||
|  | ||||
|         return Response(data) | ||||
|  | ||||
|     filter_backends = [ | ||||
|         filters.SearchFilter, | ||||
|     ] | ||||
|  | ||||
|     search_fields = [ | ||||
|         'name', | ||||
|         'description', | ||||
|         'IPN', | ||||
|         'revision', | ||||
|         'keywords', | ||||
|         'category__name', | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class PartThumbsUpdate(generics.RetrieveUpdateAPIView): | ||||
|     """ API endpoint for updating Part thumbnails""" | ||||
| @@ -443,6 +456,8 @@ class PartFilter(rest_filters.FilterSet): | ||||
|         else: | ||||
|             queryset = queryset.filter(IPN='') | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     # Regex filter for name | ||||
|     name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') | ||||
|  | ||||
| @@ -615,16 +630,75 @@ class PartList(generics.ListCreateAPIView): | ||||
|         else: | ||||
|             return Response(data) | ||||
|  | ||||
|     def perform_create(self, serializer): | ||||
|     def create(self, request, *args, **kwargs): | ||||
|         """ | ||||
|         We wish to save the user who created this part! | ||||
|  | ||||
|         Note: Implementation copied from DRF class CreateModelMixin | ||||
|         """ | ||||
|  | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         part = serializer.save() | ||||
|         part.creation_user = self.request.user | ||||
|         part.save() | ||||
|  | ||||
|         # Optionally copy templates from category or parent category | ||||
|         copy_templates = { | ||||
|             'main': str2bool(request.data.get('copy_category_templates', False)), | ||||
|             'parent': str2bool(request.data.get('copy_parent_templates', False)) | ||||
|         } | ||||
|  | ||||
|         part.save(**{'add_category_templates': copy_templates}) | ||||
|  | ||||
|         # Optionally copy data from another part (e.g. when duplicating) | ||||
|         copy_from = request.data.get('copy_from', None) | ||||
|  | ||||
|         if copy_from is not None: | ||||
|  | ||||
|             try: | ||||
|                 original = Part.objects.get(pk=copy_from) | ||||
|  | ||||
|                 copy_bom = str2bool(request.data.get('copy_bom', False)) | ||||
|                 copy_parameters = str2bool(request.data.get('copy_parameters', False)) | ||||
|                 copy_image = str2bool(request.data.get('copy_image', True)) | ||||
|  | ||||
|                 # Copy image? | ||||
|                 if copy_image: | ||||
|                     part.image = original.image | ||||
|                     part.save() | ||||
|  | ||||
|                 # Copy BOM? | ||||
|                 if copy_bom: | ||||
|                     part.copy_bom_from(original) | ||||
|  | ||||
|                 # Copy parameter data? | ||||
|                 if copy_parameters: | ||||
|                     part.copy_parameters_from(original) | ||||
|  | ||||
|             except (ValueError, Part.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|         # Optionally create initial stock item | ||||
|         try: | ||||
|             initial_stock = Decimal(request.data.get('initial_stock', 0)) | ||||
|  | ||||
|             if initial_stock > 0 and part.default_location is not None: | ||||
|  | ||||
|                 stock_item = StockItem( | ||||
|                     part=part, | ||||
|                     quantity=initial_stock, | ||||
|                     location=part.default_location, | ||||
|                 ) | ||||
|  | ||||
|                 stock_item.save(user=request.user) | ||||
|  | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|         headers = self.get_success_headers(serializer.data) | ||||
|  | ||||
|         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) | ||||
|  | ||||
|     def get_queryset(self, *args, **kwargs): | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,6 @@ import common.models | ||||
| from common.forms import MatchItemForm | ||||
|  | ||||
| from .models import Part, PartCategory, PartRelated | ||||
| from .models import BomItem | ||||
| from .models import PartParameterTemplate, PartParameter | ||||
| from .models import PartCategoryParameterTemplate | ||||
| from .models import PartSellPriceBreak, PartInternalPriceBreak | ||||
| @@ -178,82 +177,6 @@ class SetPartCategoryForm(forms.Form): | ||||
|     part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category')) | ||||
|  | ||||
|  | ||||
| class EditPartForm(HelperForm): | ||||
|     """ | ||||
|     Form for editing a Part object. | ||||
|     """ | ||||
|  | ||||
|     field_prefix = { | ||||
|         'keywords': 'fa-key', | ||||
|         'link': 'fa-link', | ||||
|         'IPN': 'fa-hashtag', | ||||
|         'default_expiry': 'fa-stopwatch', | ||||
|     } | ||||
|  | ||||
|     bom_copy = forms.BooleanField(required=False, | ||||
|                                   initial=True, | ||||
|                                   help_text=_("Duplicate all BOM data for this part"), | ||||
|                                   label=_('Copy BOM'), | ||||
|                                   widget=forms.HiddenInput()) | ||||
|  | ||||
|     parameters_copy = forms.BooleanField(required=False, | ||||
|                                          initial=True, | ||||
|                                          help_text=_("Duplicate all parameter data for this part"), | ||||
|                                          label=_('Copy Parameters'), | ||||
|                                          widget=forms.HiddenInput()) | ||||
|  | ||||
|     confirm_creation = forms.BooleanField(required=False, | ||||
|                                           initial=False, | ||||
|                                           help_text=_('Confirm part creation'), | ||||
|                                           widget=forms.HiddenInput()) | ||||
|  | ||||
|     selected_category_templates = forms.BooleanField(required=False, | ||||
|                                                      initial=False, | ||||
|                                                      label=_('Include category parameter templates'), | ||||
|                                                      widget=forms.HiddenInput()) | ||||
|  | ||||
|     parent_category_templates = forms.BooleanField(required=False, | ||||
|                                                    initial=False, | ||||
|                                                    label=_('Include parent categories parameter templates'), | ||||
|                                                    widget=forms.HiddenInput()) | ||||
|  | ||||
|     initial_stock = forms.IntegerField(required=False, | ||||
|                                        initial=0, | ||||
|                                        label=_('Initial stock amount'), | ||||
|                                        help_text=_('Create stock for this part')) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Part | ||||
|         fields = [ | ||||
|             'confirm_creation', | ||||
|             'category', | ||||
|             'selected_category_templates', | ||||
|             'parent_category_templates', | ||||
|             'name', | ||||
|             'IPN', | ||||
|             'description', | ||||
|             'revision', | ||||
|             'bom_copy', | ||||
|             'parameters_copy', | ||||
|             'keywords', | ||||
|             'variant_of', | ||||
|             'link', | ||||
|             'default_location', | ||||
|             'default_supplier', | ||||
|             'default_expiry', | ||||
|             'units', | ||||
|             'minimum_stock', | ||||
|             'initial_stock', | ||||
|             'component', | ||||
|             'assembly', | ||||
|             'is_template', | ||||
|             'trackable', | ||||
|             'purchaseable', | ||||
|             'salable', | ||||
|             'virtual', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EditPartParameterTemplateForm(HelperForm): | ||||
|     """ Form for editing a PartParameterTemplate object """ | ||||
|  | ||||
| @@ -317,33 +240,6 @@ class EditCategoryParameterTemplateForm(HelperForm): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EditBomItemForm(HelperForm): | ||||
|     """ Form for editing a BomItem object """ | ||||
|  | ||||
|     quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity')) | ||||
|  | ||||
|     sub_part = PartModelChoiceField(queryset=Part.objects.all(), label=_('Sub part')) | ||||
|  | ||||
|     class Meta: | ||||
|         model = BomItem | ||||
|         fields = [ | ||||
|             'part', | ||||
|             'sub_part', | ||||
|             'quantity', | ||||
|             'reference', | ||||
|             'overage', | ||||
|             'note', | ||||
|             'allow_variants', | ||||
|             'inherited', | ||||
|             'optional', | ||||
|         ] | ||||
|  | ||||
|         # Prevent editing of the part associated with this BomItem | ||||
|         widgets = { | ||||
|             'part': forms.HiddenInput() | ||||
|         } | ||||
|  | ||||
|  | ||||
| class PartPriceForm(forms.Form): | ||||
|     """ Simple form for viewing part pricing information """ | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,6 @@ from stdimage.models import StdImageField | ||||
|  | ||||
| from decimal import Decimal, InvalidOperation | ||||
| from datetime import datetime | ||||
| from rapidfuzz import fuzz | ||||
| import hashlib | ||||
|  | ||||
| from InvenTree import helpers | ||||
| @@ -235,57 +234,6 @@ def rename_part_image(instance, filename): | ||||
|     return os.path.join(base, fname) | ||||
|  | ||||
|  | ||||
| def match_part_names(match, threshold=80, reverse=True, compare_length=False): | ||||
|     """ Return a list of parts whose name matches the search term using fuzzy search. | ||||
|  | ||||
|     Args: | ||||
|         match: Term to match against | ||||
|         threshold: Match percentage that must be exceeded (default = 65) | ||||
|         reverse: Ordering for search results (default = True - highest match is first) | ||||
|         compare_length: Include string length checks | ||||
|  | ||||
|     Returns: | ||||
|         A sorted dict where each element contains the following key:value pairs: | ||||
|             - 'part' : The matched part | ||||
|             - 'ratio' : The matched ratio | ||||
|     """ | ||||
|  | ||||
|     match = str(match).strip().lower() | ||||
|  | ||||
|     if len(match) == 0: | ||||
|         return [] | ||||
|  | ||||
|     parts = Part.objects.all() | ||||
|  | ||||
|     matches = [] | ||||
|  | ||||
|     for part in parts: | ||||
|         compare = str(part.name).strip().lower() | ||||
|  | ||||
|         if len(compare) == 0: | ||||
|             continue | ||||
|  | ||||
|         ratio = fuzz.partial_token_sort_ratio(compare, match) | ||||
|  | ||||
|         if compare_length: | ||||
|             # Also employ primitive length comparison | ||||
|             # TODO - Improve this somewhat... | ||||
|             l_min = min(len(match), len(compare)) | ||||
|             l_max = max(len(match), len(compare)) | ||||
|  | ||||
|             ratio *= (l_min / l_max) | ||||
|  | ||||
|         if ratio >= threshold: | ||||
|             matches.append({ | ||||
|                 'part': part, | ||||
|                 'ratio': round(ratio, 1) | ||||
|             }) | ||||
|  | ||||
|     matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse) | ||||
|  | ||||
|     return matches | ||||
|  | ||||
|  | ||||
| class PartManager(TreeManager): | ||||
|     """ | ||||
|     Defines a custom object manager for the Part model. | ||||
| @@ -409,7 +357,7 @@ class Part(MPTTModel): | ||||
|         """ | ||||
|  | ||||
|         # Get category templates settings | ||||
|         add_category_templates = kwargs.pop('add_category_templates', None) | ||||
|         add_category_templates = kwargs.pop('add_category_templates', False) | ||||
|  | ||||
|         if self.pk: | ||||
|             previous = Part.objects.get(pk=self.pk) | ||||
| @@ -437,39 +385,29 @@ class Part(MPTTModel): | ||||
|             # Get part category | ||||
|             category = self.category | ||||
|  | ||||
|             if category and add_category_templates: | ||||
|                 # Store templates added to part | ||||
|             if category is not None: | ||||
|  | ||||
|                 template_list = [] | ||||
|  | ||||
|                 # Create part parameters for selected category | ||||
|                 category_templates = add_category_templates['main'] | ||||
|                 if category_templates: | ||||
|                 parent_categories = category.get_ancestors(include_self=True) | ||||
|  | ||||
|                 for category in parent_categories: | ||||
|                     for template in category.get_parameter_templates(): | ||||
|                         parameter = PartParameter.create(part=self, | ||||
|                                                          template=template.parameter_template, | ||||
|                                                          data=template.default_value, | ||||
|                                                          save=True) | ||||
|                         if parameter: | ||||
|                         # Check that template wasn't already added | ||||
|                         if template.parameter_template not in template_list: | ||||
|  | ||||
|                             template_list.append(template.parameter_template) | ||||
|  | ||||
|                 # Create part parameters for parent category | ||||
|                 category_templates = add_category_templates['parent'] | ||||
|                 if category_templates: | ||||
|                     # Get parent categories | ||||
|                     parent_categories = category.get_ancestors() | ||||
|  | ||||
|                     for category in parent_categories: | ||||
|                         for template in category.get_parameter_templates(): | ||||
|                             # Check that template wasn't already added | ||||
|                             if template.parameter_template not in template_list: | ||||
|                                 try: | ||||
|                                     PartParameter.create(part=self, | ||||
|                                                          template=template.parameter_template, | ||||
|                                                          data=template.default_value, | ||||
|                                                          save=True) | ||||
|                                 except IntegrityError: | ||||
|                                     # PartParameter already exists | ||||
|                                     pass | ||||
|                             try: | ||||
|                                 PartParameter.create( | ||||
|                                     part=self, | ||||
|                                     template=template.parameter_template, | ||||
|                                     data=template.default_value, | ||||
|                                     save=True | ||||
|                                 ) | ||||
|                             except IntegrityError: | ||||
|                                 # PartParameter already exists | ||||
|                                 pass | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"{self.full_name} - {self.description}" | ||||
|   | ||||
| @@ -240,32 +240,20 @@ | ||||
|     }); | ||||
|  | ||||
|     $("#cat-create").click(function() { | ||||
|         launchModalForm( | ||||
|             "{% url 'category-create' %}", | ||||
|             { | ||||
|                 follow: true, | ||||
|                 data: { | ||||
|                     {% if category %} | ||||
|                     category: {{ category.id }} | ||||
|                     {% endif %} | ||||
|                 }, | ||||
|                 secondary: [ | ||||
|                     { | ||||
|                         field: 'default_location', | ||||
|                         label: '{% trans "New Location" %}', | ||||
|                         title: '{% trans "Create new location" %}', | ||||
|                         url: "{% url 'stock-location-create' %}", | ||||
|                     }, | ||||
|                     { | ||||
|                         field: 'parent', | ||||
|                         label: '{% trans "New Category" %}', | ||||
|                         title: '{% trans "Create new category" %}', | ||||
|                         url: "{% url 'category-create' %}", | ||||
|                     }, | ||||
|                 ] | ||||
|             } | ||||
|         ); | ||||
|     }) | ||||
|  | ||||
|         var fields = categoryFields(); | ||||
|  | ||||
|         {% if category %} | ||||
|         fields.parent.value = {{ category.pk }}; | ||||
|         {% endif %} | ||||
|  | ||||
|         constructForm('{% url "api-part-category-list" %}', { | ||||
|             fields: fields, | ||||
|             method: 'POST', | ||||
|             title: '{% trans "Create Part Category" %}', | ||||
|             follow: true, | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     $("#part-export").click(function() { | ||||
|  | ||||
| @@ -276,55 +264,32 @@ | ||||
|  | ||||
|     {% if roles.part.add %} | ||||
|     $("#part-create").click(function() { | ||||
|         launchModalForm( | ||||
|             "{% url 'part-create' %}", | ||||
|             { | ||||
|                 follow: true, | ||||
|                 data: { | ||||
|                 {% if category %} | ||||
|                     category: {{ category.id }} | ||||
|                 {% endif %} | ||||
|                 }, | ||||
|                 secondary: [ | ||||
|                     { | ||||
|                         field: 'category', | ||||
|                         label: '{% trans "New Category" %}', | ||||
|                         title: '{% trans "Create new Part Category" %}', | ||||
|                         url: "{% url 'category-create' %}", | ||||
|                     }, | ||||
|                     { | ||||
|                         field: 'default_location', | ||||
|                         label: '{% trans "New Location" %}', | ||||
|                         title: '{% trans "Create new Stock Location" %}', | ||||
|                         url: "{% url 'stock-location-create' %}", | ||||
|                     } | ||||
|                 ]    | ||||
|             } | ||||
|         ); | ||||
|  | ||||
|         var fields = partFields({ | ||||
|             create: true, | ||||
|         }); | ||||
|  | ||||
|         {% if category %} | ||||
|         fields.category.value = {{ category.pk }}; | ||||
|         {% endif %} | ||||
|  | ||||
|         constructForm('{% url "api-part-list" %}', { | ||||
|             method: 'POST', | ||||
|             fields: fields, | ||||
|             title: '{% trans "Create Part" %}', | ||||
|             onSuccess: function(data) { | ||||
|                 // Follow the new part | ||||
|                 location.href = `/part/${data.pk}/`; | ||||
|             }, | ||||
|         }); | ||||
|          | ||||
|     }); | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if category %} | ||||
|     $("#cat-edit").click(function () { | ||||
|  | ||||
|         constructForm( | ||||
|             '{% url "api-part-category-detail" category.pk %}', | ||||
|             { | ||||
|                 fields: { | ||||
|                     name: {}, | ||||
|                     description: {}, | ||||
|                     parent: { | ||||
|                         help_text: '{% trans "Select parent category" %}', | ||||
|                     }, | ||||
|                     default_location: {}, | ||||
|                     default_keywords: { | ||||
|                         icon: 'fa-key', | ||||
|                     } | ||||
|                 }, | ||||
|                 title: '{% trans "Edit Part Category" %}', | ||||
|                 reload: true | ||||
|             } | ||||
|         ); | ||||
|         editCategory({{ category.pk }}); | ||||
|     }); | ||||
|  | ||||
|     {% if category.parent %} | ||||
|   | ||||
| @@ -440,22 +440,22 @@ | ||||
|     }); | ||||
|  | ||||
|     $("#bom-item-new").click(function () { | ||||
|         launchModalForm( | ||||
|             "{% url 'bom-item-create' %}?parent={{ part.id }}", | ||||
|             { | ||||
|                 success: function() { | ||||
|                     $("#bom-table").bootstrapTable('refresh'); | ||||
|                 }, | ||||
|                 secondary: [ | ||||
|                     { | ||||
|                         field: 'sub_part', | ||||
|                         label: '{% trans "New Part" %}', | ||||
|                         title: '{% trans "Create New Part" %}', | ||||
|                         url: "{% url 'part-create' %}", | ||||
|                     }, | ||||
|                 ] | ||||
|  | ||||
|         var fields = bomItemFields(); | ||||
|  | ||||
|         fields.part.value = {{ part.pk }}; | ||||
|         fields.sub_part.filters = { | ||||
|             active: true, | ||||
|         }; | ||||
|  | ||||
|         constructForm('{% url "api-bom-list" %}', { | ||||
|             fields: fields, | ||||
|             method: 'POST', | ||||
|             title: '{% trans "Create BOM Item" %}', | ||||
|             onSuccess: function() { | ||||
|                 $('#bom-table').bootstrapTable('refresh'); | ||||
|             } | ||||
|         ); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     {% else %} | ||||
| @@ -525,10 +525,11 @@ | ||||
|     loadPartVariantTable($('#variants-table'), {{ part.pk }}); | ||||
|  | ||||
|     $('#new-variant').click(function() { | ||||
|         launchModalForm( | ||||
|             "{% url 'make-part-variant' part.id %}", | ||||
|  | ||||
|         duplicatePart( | ||||
|             {{ part.pk}}, | ||||
|             { | ||||
|                 follow: true, | ||||
|                 variant: true, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
| @@ -899,7 +900,7 @@ | ||||
|                     {% for line in price_history %}'{{ line.date }}',{% endfor %} | ||||
|                 ], | ||||
|                 datasets: [{ | ||||
|                     label: '{% blocktrans %}Single Price - {{currency}}{% endblocktrans %}', | ||||
|                     label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}', | ||||
|                     backgroundColor: 'rgba(255, 99, 132, 0.2)', | ||||
|                     borderColor: 'rgb(255, 99, 132)', | ||||
|                     yAxisID: 'y', | ||||
| @@ -911,7 +912,7 @@ | ||||
|                 }, | ||||
|                 {% if 'price_diff' in price_history.0 %} | ||||
|                 { | ||||
|                     label: '{% blocktrans %}Single Price Difference - {{currency}}{% endblocktrans %}', | ||||
|                     label: '{% blocktrans %}Unit Price-Cost Difference - {{currency}}{% endblocktrans %}', | ||||
|                     backgroundColor: 'rgba(68, 157, 68, 0.2)', | ||||
|                     borderColor: 'rgb(68, 157, 68)', | ||||
|                     yAxisID: 'y2', | ||||
| @@ -923,7 +924,7 @@ | ||||
|                     hidden: true, | ||||
|                 }, | ||||
|                 { | ||||
|                     label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}', | ||||
|                     label: '{% blocktrans %}Supplier Unit Cost - {{currency}}{% endblocktrans %}', | ||||
|                     backgroundColor: 'rgba(70, 127, 155, 0.2)', | ||||
|                     borderColor: 'rgb(70, 127, 155)', | ||||
|                     yAxisID: 'y', | ||||
|   | ||||
| @@ -415,13 +415,16 @@ | ||||
|         // Callback when the image-selection modal form is displayed | ||||
|         // Populate the form with image data (requested via AJAX) | ||||
|  | ||||
|         $("#modal-form").find("#image-select-table").bootstrapTable({ | ||||
|             pagination: true, | ||||
|             pageSize: 25, | ||||
|         $("#modal-form").find("#image-select-table").inventreeTable({ | ||||
|             url: "{% url 'api-part-thumbs' %}", | ||||
|             showHeader: false, | ||||
|             showColumns: false, | ||||
|             clickToSelect: true, | ||||
|             sidePagination: 'server', | ||||
|             singleSelect: true, | ||||
|             formatNoMatches: function() { | ||||
|                 return '{% trans "No matching images found" %}'; | ||||
|             }, | ||||
|             columns: [ | ||||
|                 { | ||||
|                     checkbox: true, | ||||
| @@ -429,6 +432,7 @@ | ||||
|                 { | ||||
|                     field: 'image', | ||||
|                     title: 'Image', | ||||
|                     searchable: true, | ||||
|                     formatter: function(value, row, index, field) { | ||||
|                         return "<img src='/media/" + value + "' class='grid-image'/>" | ||||
|                     } | ||||
| @@ -482,12 +486,7 @@ | ||||
|  | ||||
|     {% if roles.part.add %} | ||||
|     $("#part-duplicate").click(function() { | ||||
|         launchModalForm( | ||||
|             "{% url 'part-duplicate' part.id %}", | ||||
|             { | ||||
|                 follow: true, | ||||
|             } | ||||
|         ); | ||||
|         duplicatePart({{ part.pk }}); | ||||
|     }); | ||||
|     {% endif %} | ||||
|  | ||||
|   | ||||
| @@ -161,7 +161,7 @@ | ||||
| <div class='panel-content'> | ||||
|     <h4>{% trans 'Stock Pricing' %} | ||||
|         <i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part. | ||||
|     The part single price is the current purchase price for that supplier part."></i> | ||||
|     The Supplier Unit Cost is the current purchase price for that supplier part."></i> | ||||
|     </h4> | ||||
|         {% if price_history|length > 0 %} | ||||
|             <div style="max-width: 99%; min-height: 300px"> | ||||
|   | ||||
| @@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase): | ||||
|         self.assertTrue(data['active']) | ||||
|         self.assertFalse(data['virtual']) | ||||
|  | ||||
|         # By default, parts are not purchaseable | ||||
|         self.assertFalse(data['purchaseable']) | ||||
|         # By default, parts are purchaseable | ||||
|         self.assertTrue(data['purchaseable']) | ||||
|  | ||||
|         # Set the default 'purchaseable' status to True | ||||
|         InvenTreeSetting.set_setting( | ||||
|   | ||||
| @@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError | ||||
| import os | ||||
|  | ||||
| from .models import Part, PartCategory, PartTestTemplate | ||||
| from .models import rename_part_image, match_part_names | ||||
| from .models import rename_part_image | ||||
| from .templatetags import inventree_extras | ||||
|  | ||||
| import part.settings | ||||
| @@ -163,12 +163,6 @@ class PartTest(TestCase): | ||||
|     def test_copy(self): | ||||
|         self.r2.deep_copy(self.r1, image=True, bom=True) | ||||
|  | ||||
|     def test_match_names(self): | ||||
|  | ||||
|         matches = match_part_names('M2x5 LPHS') | ||||
|  | ||||
|         self.assertTrue(len(matches) > 0) | ||||
|  | ||||
|     def test_sell_pricing(self): | ||||
|         # check that the sell pricebreaks were loaded | ||||
|         self.assertTrue(self.r1.has_price_breaks) | ||||
| @@ -281,7 +275,7 @@ class PartSettingsTest(TestCase): | ||||
|         """ | ||||
|  | ||||
|         self.assertTrue(part.settings.part_component_default()) | ||||
|         self.assertFalse(part.settings.part_purchaseable_default()) | ||||
|         self.assertTrue(part.settings.part_purchaseable_default()) | ||||
|         self.assertFalse(part.settings.part_salable_default()) | ||||
|         self.assertFalse(part.settings.part_trackable_default()) | ||||
|  | ||||
| @@ -293,7 +287,7 @@ class PartSettingsTest(TestCase): | ||||
|         part = self.make_part() | ||||
|  | ||||
|         self.assertTrue(part.component) | ||||
|         self.assertFalse(part.purchaseable) | ||||
|         self.assertTrue(part.purchaseable) | ||||
|         self.assertFalse(part.salable) | ||||
|         self.assertFalse(part.trackable) | ||||
|  | ||||
|   | ||||
| @@ -155,38 +155,6 @@ class PartDetailTest(PartViewTestCase): | ||||
|         self.assertIn('streaming_content', dir(response)) | ||||
|  | ||||
|  | ||||
| class PartTests(PartViewTestCase): | ||||
|     """ Tests for Part forms """ | ||||
|  | ||||
|     def test_part_create(self): | ||||
|         """ Launch form to create a new part """ | ||||
|         response = self.client.get(reverse('part-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # And again, with an invalid category | ||||
|         response = self.client.get(reverse('part-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # And again, with no category | ||||
|         response = self.client.get(reverse('part-create'), {'name': 'Test part'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_part_duplicate(self): | ||||
|         """ Launch form to duplicate part """ | ||||
|  | ||||
|         # First try with an invalid part | ||||
|         response = self.client.get(reverse('part-duplicate', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.get(reverse('part-duplicate', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_make_variant(self): | ||||
|  | ||||
|         response = self.client.get(reverse('make-part-variant', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|  | ||||
| class PartRelatedTests(PartViewTestCase): | ||||
|  | ||||
|     def test_valid_create(self): | ||||
| @@ -243,19 +211,6 @@ class PartQRTest(PartViewTestCase): | ||||
| class CategoryTest(PartViewTestCase): | ||||
|     """ Tests for PartCategory related views """ | ||||
|  | ||||
|     def test_create(self): | ||||
|         """ Test view for creating a new category """ | ||||
|         response = self.client.get(reverse('category-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_create_invalid_parent(self): | ||||
|         """ test creation of a new category with an invalid parent """ | ||||
|         response = self.client.get(reverse('category-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|  | ||||
|         # Form should still return OK | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_set_category(self): | ||||
|         """ Test that the "SetCategory" view works """ | ||||
|  | ||||
| @@ -272,22 +227,3 @@ class CategoryTest(PartViewTestCase): | ||||
|  | ||||
|         response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|  | ||||
| class BomItemTests(PartViewTestCase): | ||||
|     """ Tests for BomItem related views """ | ||||
|  | ||||
|     def test_create_valid_parent(self): | ||||
|         """ Create a BomItem for a valid part """ | ||||
|         response = self.client.get(reverse('bom-item-create'), {'parent': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_create_no_parent(self): | ||||
|         """ Create a BomItem without a parent """ | ||||
|         response = self.client.get(reverse('bom-item-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_create_invalid_parent(self): | ||||
|         """ Create a BomItem with an invalid parent """ | ||||
|         response = self.client.get(reverse('bom-item-create'), {'parent': 99999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|   | ||||
| @@ -40,8 +40,7 @@ part_detail_urls = [ | ||||
|     url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), | ||||
|     url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), | ||||
|     url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), | ||||
|     url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), | ||||
|     url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), | ||||
|      | ||||
|     url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), | ||||
|  | ||||
|     url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), | ||||
| @@ -65,9 +64,6 @@ category_parameter_urls = [ | ||||
|  | ||||
| category_urls = [ | ||||
|  | ||||
|     # Create a new category | ||||
|     url(r'^new/', views.CategoryCreate.as_view(), name='category-create'), | ||||
|  | ||||
|     # Top level subcategory display | ||||
|     url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'), | ||||
|  | ||||
| @@ -81,23 +77,13 @@ category_urls = [ | ||||
|     ])) | ||||
| ] | ||||
|  | ||||
| part_bom_urls = [ | ||||
|     url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'), | ||||
| ] | ||||
|  | ||||
| # URL list for part web interface | ||||
| part_urls = [ | ||||
|  | ||||
|     # Create a new part | ||||
|     url(r'^new/?', views.PartCreate.as_view(), name='part-create'), | ||||
|  | ||||
|     # Upload a part | ||||
|     url(r'^import/', views.PartImport.as_view(), name='part-import'), | ||||
|     url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), | ||||
|  | ||||
|     # Create a new BOM item | ||||
|     url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'), | ||||
|  | ||||
|     # Download a BOM upload template | ||||
|     url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), | ||||
|  | ||||
| @@ -125,9 +111,6 @@ part_urls = [ | ||||
|     # Change category for multiple parts | ||||
|     url(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'), | ||||
|  | ||||
|     # Bom Items | ||||
|     url(r'^bom/(?P<pk>\d+)/', include(part_bom_urls)), | ||||
|  | ||||
|     # Individual part using IPN as slug | ||||
|     url(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'), | ||||
|  | ||||
|   | ||||
| @@ -12,10 +12,9 @@ from django.db.utils import IntegrityError | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.shortcuts import HttpResponseRedirect | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from django.urls import reverse, reverse_lazy | ||||
| from django.urls import reverse | ||||
| from django.views.generic import DetailView, ListView | ||||
| from django.forms.models import model_to_dict | ||||
| from django.forms import HiddenInput, CheckboxInput | ||||
| from django.forms import HiddenInput | ||||
| from django.conf import settings | ||||
| from django.contrib import messages | ||||
|  | ||||
| @@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated | ||||
| from .models import PartParameterTemplate | ||||
| from .models import PartCategoryParameterTemplate | ||||
| from .models import BomItem | ||||
| from .models import match_part_names | ||||
| from .models import PartSellPriceBreak, PartInternalPriceBreak | ||||
|  | ||||
| from common.models import InvenTreeSetting | ||||
| @@ -44,7 +42,7 @@ from common.files import FileManager | ||||
| from common.views import FileManagementFormView, FileManagementAjaxView | ||||
| from common.forms import UploadFileForm, MatchFieldForm | ||||
|  | ||||
| from stock.models import StockItem, StockLocation | ||||
| from stock.models import StockLocation | ||||
|  | ||||
| import common.settings as inventree_settings | ||||
|  | ||||
| @@ -233,370 +231,6 @@ class PartSetCategory(AjaxUpdateView): | ||||
|         return ctx | ||||
|  | ||||
|  | ||||
| class MakePartVariant(AjaxCreateView): | ||||
|     """ View for creating a new variant based on an existing template Part | ||||
|  | ||||
|     - Part <pk> is provided in the URL '/part/<pk>/make_variant/' | ||||
|     - Automatically copy relevent data (BOM, etc, etc) | ||||
|  | ||||
|     """ | ||||
|  | ||||
|     model = Part | ||||
|     form_class = part_forms.EditPartForm | ||||
|  | ||||
|     ajax_form_title = _('Create Variant') | ||||
|     ajax_template_name = 'part/variant_part.html' | ||||
|  | ||||
|     def get_part_template(self): | ||||
|         return get_object_or_404(Part, id=self.kwargs['pk']) | ||||
|  | ||||
|     def get_context_data(self): | ||||
|         return { | ||||
|             'part': self.get_part_template(), | ||||
|         } | ||||
|  | ||||
|     def get_form(self): | ||||
|         form = super(AjaxCreateView, self).get_form() | ||||
|  | ||||
|         # Hide some variant-related fields | ||||
|         # form.fields['variant_of'].widget = HiddenInput() | ||||
|  | ||||
|         # Force display of the 'bom_copy' widget | ||||
|         form.fields['bom_copy'].widget = CheckboxInput() | ||||
|  | ||||
|         # Force display of the 'parameters_copy' widget | ||||
|         form.fields['parameters_copy'].widget = CheckboxInput() | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def post(self, request, *args, **kwargs): | ||||
|  | ||||
|         form = self.get_form() | ||||
|         context = self.get_context_data() | ||||
|         part_template = self.get_part_template() | ||||
|  | ||||
|         valid = form.is_valid() | ||||
|  | ||||
|         data = { | ||||
|             'form_valid': valid, | ||||
|         } | ||||
|  | ||||
|         if valid: | ||||
|             # Create the new part variant | ||||
|             part = form.save(commit=False) | ||||
|             part.variant_of = part_template | ||||
|             part.is_template = False | ||||
|  | ||||
|             part.save() | ||||
|  | ||||
|             data['pk'] = part.pk | ||||
|             data['text'] = str(part) | ||||
|             data['url'] = part.get_absolute_url() | ||||
|  | ||||
|             bom_copy = str2bool(request.POST.get('bom_copy', False)) | ||||
|             parameters_copy = str2bool(request.POST.get('parameters_copy', False)) | ||||
|  | ||||
|             # Copy relevent information from the template part | ||||
|             part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy) | ||||
|  | ||||
|         return self.renderJsonResponse(request, form, data, context=context) | ||||
|  | ||||
|     def get_initial(self): | ||||
|  | ||||
|         part_template = self.get_part_template() | ||||
|  | ||||
|         initials = model_to_dict(part_template) | ||||
|         initials['is_template'] = False | ||||
|         initials['variant_of'] = part_template | ||||
|         initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM') | ||||
|         initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS') | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|  | ||||
| class PartDuplicate(AjaxCreateView): | ||||
|     """ View for duplicating an existing Part object. | ||||
|  | ||||
|     - Part <pk> is provided in the URL '/part/<pk>/copy/' | ||||
|     - Option for 'deep-copy' which will duplicate all BOM items (default = True) | ||||
|     """ | ||||
|  | ||||
|     model = Part | ||||
|     form_class = part_forms.EditPartForm | ||||
|  | ||||
|     ajax_form_title = _("Duplicate Part") | ||||
|     ajax_template_name = "part/copy_part.html" | ||||
|  | ||||
|     def get_data(self): | ||||
|         return { | ||||
|             'success': _('Copied part') | ||||
|         } | ||||
|  | ||||
|     def get_part_to_copy(self): | ||||
|         try: | ||||
|             return Part.objects.get(id=self.kwargs['pk']) | ||||
|         except (Part.DoesNotExist, ValueError): | ||||
|             return None | ||||
|  | ||||
|     def get_context_data(self): | ||||
|         return { | ||||
|             'part': self.get_part_to_copy() | ||||
|         } | ||||
|  | ||||
|     def get_form(self): | ||||
|         form = super(AjaxCreateView, self).get_form() | ||||
|  | ||||
|         # Force display of the 'bom_copy' widget | ||||
|         form.fields['bom_copy'].widget = CheckboxInput() | ||||
|  | ||||
|         # Force display of the 'parameters_copy' widget | ||||
|         form.fields['parameters_copy'].widget = CheckboxInput() | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def post(self, request, *args, **kwargs): | ||||
|         """ Capture the POST request for part duplication | ||||
|  | ||||
|         - If the bom_copy object is set, copy all the BOM items too! | ||||
|         - If the parameters_copy object is set, copy all the parameters too! | ||||
|         """ | ||||
|  | ||||
|         form = self.get_form() | ||||
|  | ||||
|         context = self.get_context_data() | ||||
|  | ||||
|         valid = form.is_valid() | ||||
|  | ||||
|         name = request.POST.get('name', None) | ||||
|  | ||||
|         if name: | ||||
|             matches = match_part_names(name) | ||||
|  | ||||
|             if len(matches) > 0: | ||||
|                 # Display the first five closest matches | ||||
|                 context['matches'] = matches[:5] | ||||
|  | ||||
|                 # Enforce display of the checkbox | ||||
|                 form.fields['confirm_creation'].widget = CheckboxInput() | ||||
|  | ||||
|                 # Check if the user has checked the 'confirm_creation' input | ||||
|                 confirmed = str2bool(request.POST.get('confirm_creation', False)) | ||||
|  | ||||
|                 if not confirmed: | ||||
|                     msg = _('Possible matches exist - confirm creation of new part') | ||||
|                     form.add_error('confirm_creation', msg) | ||||
|                     form.pre_form_warning = msg | ||||
|                     valid = False | ||||
|  | ||||
|         data = { | ||||
|             'form_valid': valid | ||||
|         } | ||||
|  | ||||
|         if valid: | ||||
|             # Create the new Part | ||||
|             part = form.save(commit=False) | ||||
|  | ||||
|             part.creation_user = request.user | ||||
|             part.save() | ||||
|  | ||||
|             data['pk'] = part.pk | ||||
|             data['text'] = str(part) | ||||
|  | ||||
|             bom_copy = str2bool(request.POST.get('bom_copy', False)) | ||||
|             parameters_copy = str2bool(request.POST.get('parameters_copy', False)) | ||||
|  | ||||
|             original = self.get_part_to_copy() | ||||
|  | ||||
|             if original: | ||||
|                 part.deep_copy(original, bom=bom_copy, parameters=parameters_copy) | ||||
|  | ||||
|             try: | ||||
|                 data['url'] = part.get_absolute_url() | ||||
|             except AttributeError: | ||||
|                 pass | ||||
|  | ||||
|         if valid: | ||||
|             pass | ||||
|  | ||||
|         return self.renderJsonResponse(request, form, data, context=context) | ||||
|  | ||||
|     def get_initial(self): | ||||
|         """ Get initial data based on the Part to be copied from. | ||||
|         """ | ||||
|  | ||||
|         part = self.get_part_to_copy() | ||||
|  | ||||
|         if part: | ||||
|             initials = model_to_dict(part) | ||||
|         else: | ||||
|             initials = super(AjaxCreateView, self).get_initial() | ||||
|  | ||||
|         initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_BOM', True)) | ||||
|  | ||||
|         initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_PARAMETERS', True)) | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|  | ||||
| class PartCreate(AjaxCreateView): | ||||
|     """ View for creating a new Part object. | ||||
|  | ||||
|     Options for providing initial conditions: | ||||
|  | ||||
|     - Provide a category object as initial data | ||||
|     """ | ||||
|     model = Part | ||||
|     form_class = part_forms.EditPartForm | ||||
|  | ||||
|     ajax_form_title = _('Create New Part') | ||||
|     ajax_template_name = 'part/create_part.html' | ||||
|  | ||||
|     def get_data(self): | ||||
|         return { | ||||
|             'success': _("Created new part"), | ||||
|         } | ||||
|  | ||||
|     def get_category_id(self): | ||||
|         return self.request.GET.get('category', None) | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         """ Provide extra context information for the form to display: | ||||
|  | ||||
|         - Add category information (if provided) | ||||
|         """ | ||||
|         context = super(PartCreate, self).get_context_data(**kwargs) | ||||
|  | ||||
|         # Add category information to the page | ||||
|         cat_id = self.get_category_id() | ||||
|  | ||||
|         if cat_id: | ||||
|             try: | ||||
|                 context['category'] = PartCategory.objects.get(pk=cat_id) | ||||
|             except (PartCategory.DoesNotExist, ValueError): | ||||
|                 pass | ||||
|  | ||||
|         return context | ||||
|  | ||||
|     def get_form(self): | ||||
|         """ Create Form for making new Part object. | ||||
|         Remove the 'default_supplier' field as there are not yet any matching SupplierPart objects | ||||
|         """ | ||||
|         form = super(AjaxCreateView, self).get_form() | ||||
|  | ||||
|         # Hide the "default expiry" field if the feature is not enabled | ||||
|         if not inventree_settings.stock_expiry_enabled(): | ||||
|             form.fields['default_expiry'].widget = HiddenInput() | ||||
|  | ||||
|         # Hide the "initial stock amount" field if the feature is not enabled | ||||
|         if not InvenTreeSetting.get_setting('PART_CREATE_INITIAL'): | ||||
|             form.fields['initial_stock'].widget = HiddenInput() | ||||
|  | ||||
|         # Hide the default_supplier field (there are no matching supplier parts yet!) | ||||
|         form.fields['default_supplier'].widget = HiddenInput() | ||||
|  | ||||
|         # Display category templates widgets | ||||
|         form.fields['selected_category_templates'].widget = CheckboxInput() | ||||
|         form.fields['parent_category_templates'].widget = CheckboxInput() | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def post(self, request, *args, **kwargs): | ||||
|  | ||||
|         form = self.get_form() | ||||
|  | ||||
|         context = {} | ||||
|  | ||||
|         valid = form.is_valid() | ||||
|  | ||||
|         name = request.POST.get('name', None) | ||||
|  | ||||
|         if name: | ||||
|             matches = match_part_names(name) | ||||
|  | ||||
|             if len(matches) > 0: | ||||
|  | ||||
|                 # Limit to the top 5 matches (to prevent clutter) | ||||
|                 context['matches'] = matches[:5] | ||||
|  | ||||
|                 # Enforce display of the checkbox | ||||
|                 form.fields['confirm_creation'].widget = CheckboxInput() | ||||
|  | ||||
|                 # Check if the user has checked the 'confirm_creation' input | ||||
|                 confirmed = str2bool(request.POST.get('confirm_creation', False)) | ||||
|  | ||||
|                 if not confirmed: | ||||
|                     msg = _('Possible matches exist - confirm creation of new part') | ||||
|                     form.add_error('confirm_creation', msg) | ||||
|  | ||||
|                     form.pre_form_warning = msg | ||||
|                     valid = False | ||||
|  | ||||
|         data = { | ||||
|             'form_valid': valid | ||||
|         } | ||||
|  | ||||
|         if valid: | ||||
|             # Create the new Part | ||||
|             part = form.save(commit=False) | ||||
|  | ||||
|             # Record the user who created this part | ||||
|             part.creation_user = request.user | ||||
|  | ||||
|             # Store category templates settings | ||||
|             add_category_templates = { | ||||
|                 'main': form.cleaned_data['selected_category_templates'], | ||||
|                 'parent': form.cleaned_data['parent_category_templates'], | ||||
|             } | ||||
|  | ||||
|             # Save part and pass category template settings | ||||
|             part.save(**{'add_category_templates': add_category_templates}) | ||||
|  | ||||
|             # Add stock if set | ||||
|             init_stock = int(request.POST.get('initial_stock', 0)) | ||||
|             if init_stock: | ||||
|                 stock = StockItem(part=part, | ||||
|                                   quantity=init_stock, | ||||
|                                   location=part.default_location) | ||||
|                 stock.save() | ||||
|  | ||||
|             data['pk'] = part.pk | ||||
|             data['text'] = str(part) | ||||
|  | ||||
|             try: | ||||
|                 data['url'] = part.get_absolute_url() | ||||
|             except AttributeError: | ||||
|                 pass | ||||
|  | ||||
|         return self.renderJsonResponse(request, form, data, context=context) | ||||
|  | ||||
|     def get_initial(self): | ||||
|         """ Get initial data for the new Part object: | ||||
|  | ||||
|         - If a category is provided, pre-fill the Category field | ||||
|         """ | ||||
|  | ||||
|         initials = super(PartCreate, self).get_initial() | ||||
|  | ||||
|         if self.get_category_id(): | ||||
|             try: | ||||
|                 category = PartCategory.objects.get(pk=self.get_category_id()) | ||||
|                 initials['category'] = category | ||||
|                 initials['keywords'] = category.default_keywords | ||||
|             except (PartCategory.DoesNotExist, ValueError): | ||||
|                 pass | ||||
|  | ||||
|         # Allow initial data to be passed through as arguments | ||||
|         for label in ['name', 'IPN', 'description', 'revision', 'keywords']: | ||||
|             if label in self.request.GET: | ||||
|                 initials[label] = self.request.GET.get(label) | ||||
|  | ||||
|         # Automatically create part parameters from category templates | ||||
|         initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False)) | ||||
|         initials['parent_category_templates'] = initials['selected_category_templates'] | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|  | ||||
| class PartImport(FileManagementFormView): | ||||
|     ''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' | ||||
|     permission_required = 'part.add' | ||||
| @@ -1905,49 +1539,6 @@ class CategoryDelete(AjaxDeleteView): | ||||
|         } | ||||
|  | ||||
|  | ||||
| class CategoryCreate(AjaxCreateView): | ||||
|     """ Create view to make a new PartCategory """ | ||||
|     model = PartCategory | ||||
|     ajax_form_action = reverse_lazy('category-create') | ||||
|     ajax_form_title = _('Create new part category') | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     form_class = part_forms.EditCategoryForm | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         """ Add extra context data to template. | ||||
|  | ||||
|         - If parent category provided, pass the category details to the template | ||||
|         """ | ||||
|         context = super(CategoryCreate, self).get_context_data(**kwargs).copy() | ||||
|  | ||||
|         parent_id = self.request.GET.get('category', None) | ||||
|  | ||||
|         if parent_id: | ||||
|             try: | ||||
|                 context['category'] = PartCategory.objects.get(pk=parent_id) | ||||
|             except PartCategory.DoesNotExist: | ||||
|                 pass | ||||
|  | ||||
|         return context | ||||
|  | ||||
|     def get_initial(self): | ||||
|         """ Get initial data for new PartCategory | ||||
|  | ||||
|         - If parent provided, pre-fill the parent category | ||||
|         """ | ||||
|         initials = super(CategoryCreate, self).get_initial().copy() | ||||
|  | ||||
|         parent_id = self.request.GET.get('category', None) | ||||
|  | ||||
|         if parent_id: | ||||
|             try: | ||||
|                 initials['parent'] = PartCategory.objects.get(pk=parent_id) | ||||
|             except PartCategory.DoesNotExist: | ||||
|                 pass | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|  | ||||
| class CategoryParameterTemplateCreate(AjaxCreateView): | ||||
|     """ View for creating a new PartCategoryParameterTemplate """ | ||||
|  | ||||
| @@ -2121,134 +1712,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView): | ||||
|         return self.object | ||||
|  | ||||
|  | ||||
| class BomItemCreate(AjaxCreateView): | ||||
|     """ | ||||
|     Create view for making a new BomItem object | ||||
|     """ | ||||
|  | ||||
|     model = BomItem | ||||
|     form_class = part_forms.EditBomItemForm | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     ajax_form_title = _('Create BOM Item') | ||||
|  | ||||
|     def get_form(self): | ||||
|         """ Override get_form() method to reduce Part selection options. | ||||
|  | ||||
|         - Do not allow part to be added to its own BOM | ||||
|         - Remove any Part items that are already in the BOM | ||||
|         """ | ||||
|  | ||||
|         form = super(AjaxCreateView, self).get_form() | ||||
|  | ||||
|         part_id = form['part'].value() | ||||
|  | ||||
|         # Construct a queryset for the part field | ||||
|         part_query = Part.objects.filter(active=True) | ||||
|  | ||||
|         # Construct a queryset for the sub_part field | ||||
|         sub_part_query = Part.objects.filter( | ||||
|             component=True, | ||||
|             active=True | ||||
|         ) | ||||
|  | ||||
|         try: | ||||
|             part = Part.objects.get(id=part_id) | ||||
|  | ||||
|             # Hide the 'part' field | ||||
|             form.fields['part'].widget = HiddenInput() | ||||
|  | ||||
|             # Exclude the part from its own BOM | ||||
|             sub_part_query = sub_part_query.exclude(id=part.id) | ||||
|  | ||||
|             # Eliminate any options that are already in the BOM! | ||||
|             sub_part_query = sub_part_query.exclude(id__in=[item.id for item in part.getRequiredParts()]) | ||||
|  | ||||
|         except (ValueError, Part.DoesNotExist): | ||||
|             pass | ||||
|  | ||||
|         # Set the querysets for the fields | ||||
|         form.fields['part'].queryset = part_query | ||||
|         form.fields['sub_part'].queryset = sub_part_query | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def get_initial(self): | ||||
|         """ Provide initial data for the BomItem: | ||||
|  | ||||
|         - If 'parent' provided, set the parent part field | ||||
|         """ | ||||
|  | ||||
|         # Look for initial values | ||||
|         initials = super(BomItemCreate, self).get_initial().copy() | ||||
|  | ||||
|         # Parent part for this item? | ||||
|         parent_id = self.request.GET.get('parent', None) | ||||
|  | ||||
|         if parent_id: | ||||
|             try: | ||||
|                 initials['part'] = Part.objects.get(pk=parent_id) | ||||
|             except Part.DoesNotExist: | ||||
|                 pass | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|  | ||||
| class BomItemEdit(AjaxUpdateView): | ||||
|     """ Update view for editing BomItem """ | ||||
|  | ||||
|     model = BomItem | ||||
|     form_class = part_forms.EditBomItemForm | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     ajax_form_title = _('Edit BOM item') | ||||
|  | ||||
|     def get_form(self): | ||||
|         """ Override get_form() method to filter part selection options | ||||
|  | ||||
|         - Do not allow part to be added to its own BOM | ||||
|         - Remove any part items that are already in the BOM | ||||
|         """ | ||||
|  | ||||
|         item = self.get_object() | ||||
|  | ||||
|         form = super().get_form() | ||||
|  | ||||
|         part_id = form['part'].value() | ||||
|  | ||||
|         try: | ||||
|             part = Part.objects.get(pk=part_id) | ||||
|  | ||||
|             # Construct a queryset | ||||
|             query = Part.objects.filter(component=True) | ||||
|  | ||||
|             # Limit to "active" items, *unless* the currently selected item is not active | ||||
|             if item.sub_part.active: | ||||
|                 query = query.filter(active=True) | ||||
|  | ||||
|             # Prevent the parent part from being selected | ||||
|             query = query.exclude(pk=part_id) | ||||
|  | ||||
|             # Eliminate any options that are already in the BOM, | ||||
|             # *except* for the item which is already selected | ||||
|             try: | ||||
|                 sub_part_id = int(form['sub_part'].value()) | ||||
|             except ValueError: | ||||
|                 sub_part_id = -1 | ||||
|  | ||||
|             existing = [item.pk for item in part.getRequiredParts()] | ||||
|  | ||||
|             if sub_part_id in existing: | ||||
|                 existing.remove(sub_part_id) | ||||
|  | ||||
|             query = query.exclude(id__in=existing) | ||||
|  | ||||
|             form.fields['sub_part'].queryset = query | ||||
|  | ||||
|         except (ValueError, Part.DoesNotExist): | ||||
|             pass | ||||
|  | ||||
|         return form | ||||
|  | ||||
|  | ||||
| class PartSalePriceBreakCreate(AjaxCreateView): | ||||
|     """ | ||||
|     View for creating a sale price break for a part | ||||
|   | ||||
| @@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}"; | ||||
|  | ||||
| {% block header_content %} | ||||
|     <!-- TODO - Make the company logo asset generic --> | ||||
|     <img class='logo' src="{% asset 'company_logo.png' %}" alt="hello" width="150"> | ||||
|     <img class='logo' src="{% asset 'company_logo.png' %}" alt="logo" width="150"> | ||||
|  | ||||
|     <div class='header-right'> | ||||
|         <h3> | ||||
|   | ||||
| @@ -8,7 +8,6 @@ from __future__ import unicode_literals | ||||
| from django import forms | ||||
| from django.forms.utils import ErrorDict | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
| from django.core.validators import MinValueValidator | ||||
| from django.core.exceptions import ValidationError | ||||
|  | ||||
| from mptt.fields import TreeNodeChoiceField | ||||
| @@ -241,14 +240,9 @@ class InstallStockForm(HelperForm): | ||||
|         help_text=_('Stock item to install') | ||||
|     ) | ||||
|  | ||||
|     quantity_to_install = RoundingDecimalFormField( | ||||
|         max_digits=10, decimal_places=5, | ||||
|         initial=1, | ||||
|         label=_('Quantity'), | ||||
|         help_text=_('Stock quantity to assign'), | ||||
|         validators=[ | ||||
|             MinValueValidator(0.001) | ||||
|         ] | ||||
|     to_install = forms.BooleanField( | ||||
|         widget=forms.HiddenInput(), | ||||
|         required=False, | ||||
|     ) | ||||
|  | ||||
|     notes = forms.CharField( | ||||
| @@ -261,7 +255,7 @@ class InstallStockForm(HelperForm): | ||||
|         fields = [ | ||||
|             'part', | ||||
|             'stock_item', | ||||
|             'quantity_to_install', | ||||
|             # 'quantity_to_install', | ||||
|             'notes', | ||||
|         ] | ||||
|  | ||||
|   | ||||
| @@ -119,6 +119,11 @@ | ||||
|         <h4>{% trans "Installed Stock Items" %}</h4> | ||||
|     </div> | ||||
|     <div class='panel-content'> | ||||
|         <div class='btn-group'> | ||||
|         <button type='button' class='btn btn-success' id='stock-item-install'> | ||||
|             <span class='fas fa-plus-circle'></span> {% trans "Install Stock Item" %} | ||||
|         </button> | ||||
|         </div> | ||||
|         <table class='table table-striped table-condensed' id='installed-table'></table> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -128,6 +133,20 @@ | ||||
| {% block js_ready %} | ||||
| {{ block.super }} | ||||
|  | ||||
|     $('#stock-item-install').click(function() { | ||||
|  | ||||
|         launchModalForm( | ||||
|             "{% url 'stock-item-install' item.pk %}", | ||||
|             { | ||||
|                 data: { | ||||
|                     'part': {{ item.part.pk }}, | ||||
|                     'install_item': true, | ||||
|                 }, | ||||
|                 reload: true, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     loadInstalledInTable( | ||||
|         $('#installed-table'), | ||||
|         { | ||||
|   | ||||
| @@ -127,9 +127,11 @@ | ||||
|                 <li><a href='#' id='stock-return-from-customer' title='{% trans "Return to stock" %}'><span class='fas fa-undo'></span> {% trans "Return to stock" %}</a></li> | ||||
|                 {% endif %} | ||||
|                 {% if item.belongs_to %} | ||||
|                 <li> | ||||
|                     <a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a> | ||||
|                 </li> | ||||
|                 <li><a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a></li> | ||||
|                 {% else %} | ||||
|                 {% if item.part.get_used_in %} | ||||
|                 <li><a href='#' id='stock-install-in' title='{% trans "Install stock item" %}'><span class='fas fa-link'></span> {% trans "Install" %}</a></li> | ||||
|                 {% endif %} | ||||
|                 {% endif %} | ||||
|             </ul> | ||||
|         </div> | ||||
| @@ -461,13 +463,27 @@ $("#stock-serialize").click(function() { | ||||
|     ); | ||||
| }); | ||||
|  | ||||
| $('#stock-install-in').click(function() { | ||||
|  | ||||
|     launchModalForm( | ||||
|         "{% url 'stock-item-install' item.pk %}", | ||||
|         { | ||||
|             data: { | ||||
|                 'part': {{ item.part.pk }}, | ||||
|                 'install_in': true, | ||||
|             }, | ||||
|             reload: true, | ||||
|         } | ||||
|     ); | ||||
| }); | ||||
|  | ||||
| $('#stock-uninstall').click(function() { | ||||
|  | ||||
|     launchModalForm( | ||||
|         "{% url 'stock-item-uninstall' %}", | ||||
|         { | ||||
|             data: { | ||||
|                 'items[]': [{{ item.pk}}], | ||||
|                 'items[]': [{{ item.pk }}], | ||||
|             }, | ||||
|             reload: true, | ||||
|         } | ||||
|   | ||||
| @@ -3,15 +3,31 @@ | ||||
|  | ||||
| {% block pre_form_content %} | ||||
|  | ||||
| {% if install_item %} | ||||
| <p> | ||||
|     {% trans "Install another StockItem into this item." %} | ||||
|     {% trans "Install another Stock Item into this item." %} | ||||
| </p> | ||||
| <p> | ||||
|     {% trans "Stock items can only be installed if they meet the following criteria" %}: | ||||
|  | ||||
|     <ul> | ||||
|         <li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li> | ||||
|         <li>{% trans "The StockItem is currently in stock" %}</li> | ||||
|         <li>{% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}</li> | ||||
|         <li>{% trans "The Stock Item is currently in stock" %}</li> | ||||
|         <li>{% trans "The Stock Item is serialized and does not belong to another item" %}</li> | ||||
|     </ul> | ||||
| </p> | ||||
| {% elif install_in %} | ||||
| <p> | ||||
|     {% trans "Install this Stock Item in another stock item." %} | ||||
| </p> | ||||
| <p> | ||||
|     {% trans "Stock items can only be installed if they meet the following criteria" %}: | ||||
|  | ||||
|     <ul> | ||||
|         <li>{% trans "The part associated to this Stock Item belongs to another part's BOM" %}</li> | ||||
|         <li>{% trans "This Stock Item is serialized and does not belong to another item" %}</li> | ||||
|     </ul> | ||||
| </p> | ||||
| {% endif %} | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -536,36 +536,73 @@ class StockItemInstall(AjaxUpdateView): | ||||
|  | ||||
|     part = None | ||||
|  | ||||
|     def get_params(self): | ||||
|         """ Retrieve GET parameters """ | ||||
|  | ||||
|         # Look at GET params | ||||
|         self.part_id = self.request.GET.get('part', None) | ||||
|         self.install_in = self.request.GET.get('install_in', False) | ||||
|         self.install_item = self.request.GET.get('install_item', False) | ||||
|  | ||||
|         if self.part_id is None: | ||||
|             # Look at POST params | ||||
|             self.part_id = self.request.POST.get('part', None) | ||||
|  | ||||
|         try: | ||||
|             self.part = Part.objects.get(pk=self.part_id) | ||||
|         except (ValueError, Part.DoesNotExist): | ||||
|             self.part = None | ||||
|  | ||||
|     def get_stock_items(self): | ||||
|         """ | ||||
|         Return a list of stock items suitable for displaying to the user. | ||||
|  | ||||
|         Requirements: | ||||
|         - Items must be in stock | ||||
|  | ||||
|         Filters: | ||||
|         - Items can be filtered by Part reference | ||||
|         - Items must be in BOM of stock item | ||||
|         - Items must be serialized | ||||
|         """ | ||||
|  | ||||
|          | ||||
|         # Filter items in stock | ||||
|         items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) | ||||
|  | ||||
|         # Filter by Part association | ||||
|         # Filter serialized stock items | ||||
|         items = items.exclude(serial__isnull=True).exclude(serial__exact='') | ||||
|  | ||||
|         # Look at GET params | ||||
|         part_id = self.request.GET.get('part', None) | ||||
|         if self.part: | ||||
|             # Filter for parts to install this item in | ||||
|             if self.install_in: | ||||
|                 # Get parts using this part | ||||
|                 allowed_parts = self.part.get_used_in() | ||||
|                 # Filter | ||||
|                 items = items.filter(part__in=allowed_parts) | ||||
|  | ||||
|         if part_id is None: | ||||
|             # Look at POST params | ||||
|             part_id = self.request.POST.get('part', None) | ||||
|  | ||||
|         try: | ||||
|             self.part = Part.objects.get(pk=part_id) | ||||
|             items = items.filter(part=self.part) | ||||
|         except (ValueError, Part.DoesNotExist): | ||||
|             self.part = None | ||||
|             # Filter for parts to install in this item | ||||
|             if self.install_item: | ||||
|                 # Get parts used in this part's BOM | ||||
|                 bom_items = self.part.get_bom_items() | ||||
|                 allowed_parts = [item.sub_part for item in bom_items] | ||||
|                 # Filter | ||||
|                 items = items.filter(part__in=allowed_parts) | ||||
|  | ||||
|         return items | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         """ Retrieve parameters and update context """ | ||||
|  | ||||
|         ctx = super().get_context_data(**kwargs) | ||||
|  | ||||
|         # Get request parameters | ||||
|         self.get_params() | ||||
|  | ||||
|         ctx.update({ | ||||
|             'part': self.part, | ||||
|             'install_in': self.install_in, | ||||
|             'install_item': self.install_item, | ||||
|         }) | ||||
|  | ||||
|         return ctx | ||||
|  | ||||
|     def get_initial(self): | ||||
|  | ||||
|         initials = super().get_initial() | ||||
| @@ -576,11 +613,16 @@ class StockItemInstall(AjaxUpdateView): | ||||
|         if items.count() == 1: | ||||
|             item = items.first() | ||||
|             initials['stock_item'] = item.pk | ||||
|             initials['quantity_to_install'] = item.quantity | ||||
|  | ||||
|         if self.part: | ||||
|             initials['part'] = self.part | ||||
|  | ||||
|         try: | ||||
|             # Is this stock item being installed in the other stock item? | ||||
|             initials['to_install'] = self.install_in or not self.install_item | ||||
|         except AttributeError: | ||||
|             pass | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|     def get_form(self): | ||||
| @@ -593,6 +635,8 @@ class StockItemInstall(AjaxUpdateView): | ||||
|  | ||||
|     def post(self, request, *args, **kwargs): | ||||
|  | ||||
|         self.get_params() | ||||
|  | ||||
|         form = self.get_form() | ||||
|  | ||||
|         valid = form.is_valid() | ||||
| @@ -602,13 +646,19 @@ class StockItemInstall(AjaxUpdateView): | ||||
|             data = form.cleaned_data | ||||
|  | ||||
|             other_stock_item = data['stock_item'] | ||||
|             quantity = data['quantity_to_install'] | ||||
|             # Quantity will always be 1 for serialized item | ||||
|             quantity = 1 | ||||
|             notes = data['notes'] | ||||
|  | ||||
|             # Install the other stock item into this one | ||||
|             # Get stock item | ||||
|             this_stock_item = self.get_object() | ||||
|  | ||||
|             this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) | ||||
|             if data['to_install']: | ||||
|                 # Install this stock item into the other stock item | ||||
|                 other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes) | ||||
|             else: | ||||
|                 # Install the other stock item into this one | ||||
|                 this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) | ||||
|  | ||||
|         data = { | ||||
|             'form_valid': valid, | ||||
|   | ||||
| @@ -8,6 +8,26 @@ | ||||
|  */ | ||||
|  | ||||
|  | ||||
| function bomItemFields() { | ||||
|  | ||||
|     return { | ||||
|         part: { | ||||
|             hidden: true, | ||||
|         }, | ||||
|         sub_part: { | ||||
|         }, | ||||
|         quantity: {}, | ||||
|         reference: {}, | ||||
|         overage: {}, | ||||
|         note: {}, | ||||
|         allow_variants: {}, | ||||
|         inherited: {}, | ||||
|         optional: {}, | ||||
|     }; | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| function reloadBomTable(table, options) { | ||||
|  | ||||
|     table.bootstrapTable('refresh'); | ||||
| @@ -262,13 +282,13 @@ function loadBomTable(table, options) { | ||||
|     cols.push( | ||||
|     { | ||||
|         field: 'price_range', | ||||
|         title: '{% trans "Buy Price" %}', | ||||
|         title: '{% trans "Supplier Cost" %}', | ||||
|         sortable: true, | ||||
|         formatter: function(value, row, index, field) { | ||||
|             if (value) { | ||||
|                 return value; | ||||
|             } else { | ||||
|                 return "<span class='warning-msg'>{% trans 'No pricing available' %}</span>"; | ||||
|                 return "<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>"; | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
| @@ -528,14 +548,15 @@ function loadBomTable(table, options) { | ||||
|             var pk = $(this).attr('pk'); | ||||
|             var url = `/part/bom/${pk}/edit/`; | ||||
|  | ||||
|             launchModalForm( | ||||
|                 url, | ||||
|                 { | ||||
|                     success: function() { | ||||
|                         reloadBomTable(table); | ||||
|                     } | ||||
|             var fields = bomItemFields(); | ||||
|  | ||||
|             constructForm(`/api/bom/${pk}/`, { | ||||
|                 fields: fields, | ||||
|                 title: '{% trans "Edit BOM Item" %}', | ||||
|                 onSuccess: function() { | ||||
|                     reloadBomTable(table); | ||||
|                 } | ||||
|             ); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         table.on('click', '.bom-validate-button', function() { | ||||
|   | ||||
| @@ -927,7 +927,7 @@ function loadBuildTable(table, options) { | ||||
|             }, | ||||
|             { | ||||
|                 field: 'responsible', | ||||
|                 title: '{% trans "Resposible" %}', | ||||
|                 title: '{% trans "Responsible" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row, index, field) { | ||||
|                     if (value) | ||||
|   | ||||
| @@ -265,6 +265,8 @@ function setupFilterList(tableKey, table, target) { | ||||
|     // One blank slate, please | ||||
|     element.empty(); | ||||
|  | ||||
|     element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`); | ||||
|  | ||||
|     element.append(`<button id='${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) { | ||||
| @@ -279,6 +281,11 @@ function setupFilterList(tableKey, table, target) { | ||||
|         element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`); | ||||
|     } | ||||
|  | ||||
|     // Callback for reloading the table | ||||
|     element.find(`#reload-${tableKey}`).click(function() { | ||||
|         $(table).bootstrapTable('refresh'); | ||||
|     }); | ||||
|  | ||||
|     // Add a callback for adding a new filter | ||||
|     element.find(`#${add}`).click(function clicked() { | ||||
|  | ||||
|   | ||||
| @@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) { | ||||
|  *      - hidden: Set to true to hide the field | ||||
|  *      - icon: font-awesome icon to display before the field | ||||
|  *      - prefix: Custom HTML prefix to display before the field | ||||
|  * - data: map of data to fill out field values with | ||||
|  * - focus: Name of field to focus on when modal is displayed | ||||
|  * - preventClose: Set to true to prevent form from closing on success | ||||
|  * - onSuccess: callback function when form action is successful | ||||
| @@ -263,6 +264,11 @@ function constructForm(url, options) { | ||||
|     // Default HTTP method | ||||
|     options.method = options.method || 'PATCH'; | ||||
|  | ||||
|     // Construct an "empty" data object if not provided | ||||
|     if (!options.data) { | ||||
|         options.data = {}; | ||||
|     } | ||||
|  | ||||
|     // Request OPTIONS endpoint from the API | ||||
|     getApiEndpointOptions(url, function(OPTIONS) { | ||||
|  | ||||
| @@ -346,10 +352,19 @@ function constructFormBody(fields, options) { | ||||
|     // otherwise *all* fields will be displayed | ||||
|     var displayed_fields = options.fields || fields; | ||||
|  | ||||
|     // Handle initial data overrides | ||||
|     if (options.data) { | ||||
|         for (const field in options.data) { | ||||
|  | ||||
|             if (field in fields) { | ||||
|                 fields[field].value = options.data[field]; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Provide each field object with its own name | ||||
|     for(field in fields) { | ||||
|         fields[field].name = field; | ||||
|  | ||||
|          | ||||
|         // If any "instance_filters" are defined for the endpoint, copy them across (overwrite) | ||||
|         if (fields[field].instance_filters) { | ||||
| @@ -366,6 +381,10 @@ function constructFormBody(fields, options) { | ||||
|  | ||||
|             // TODO: Refactor the following code with Object.assign (see above) | ||||
|  | ||||
|             // "before" and "after" renders | ||||
|             fields[field].before = field_options.before; | ||||
|             fields[field].after = field_options.after; | ||||
|  | ||||
|             // Secondary modal options | ||||
|             fields[field].secondary = field_options.secondary; | ||||
|  | ||||
| @@ -560,10 +579,15 @@ function submitFormData(fields, options) { | ||||
|     var has_files = false; | ||||
|  | ||||
|     // Extract values for each field | ||||
|     options.field_names.forEach(function(name) { | ||||
|     for (var idx = 0; idx < options.field_names.length; idx++) { | ||||
|  | ||||
|         var name = options.field_names[idx]; | ||||
|  | ||||
|         var field = fields[name] || null; | ||||
|  | ||||
|         // Ignore visual fields | ||||
|         if (field && field.type == 'candy') continue; | ||||
|  | ||||
|         if (field) { | ||||
|  | ||||
|             var value = getFormFieldValue(name, field, options); | ||||
| @@ -593,7 +617,7 @@ function submitFormData(fields, options) { | ||||
|         } else { | ||||
|             console.log(`WARNING: Could not find field matching '${name}'`); | ||||
|         } | ||||
|     }); | ||||
|     } | ||||
|  | ||||
|     var upload_func = inventreePut; | ||||
|  | ||||
| @@ -1279,6 +1303,11 @@ function renderModelData(name, model, data, parameters, options) { | ||||
|  */ | ||||
| function constructField(name, parameters, options) { | ||||
|  | ||||
|     // Shortcut for simple visual fields | ||||
|     if (parameters.type == 'candy') { | ||||
|         return constructCandyInput(name, parameters, options); | ||||
|     } | ||||
|  | ||||
|     var field_name = `id_${name}`; | ||||
|  | ||||
|     // Hidden inputs are rendered without label / help text / etc | ||||
| @@ -1292,7 +1321,14 @@ function constructField(name, parameters, options) { | ||||
|         form_classes += ' has-error'; | ||||
|     } | ||||
|  | ||||
|     var html = `<div id='div_${field_name}' class='${form_classes}'>`; | ||||
|     var html = ''; | ||||
|      | ||||
|     // Optional content to render before the field | ||||
|     if (parameters.before) { | ||||
|         html += parameters.before; | ||||
|     } | ||||
|      | ||||
|     html += `<div id='div_${field_name}' class='${form_classes}'>`; | ||||
|  | ||||
|     // Add a label | ||||
|     html += constructLabel(name, parameters); | ||||
| @@ -1352,6 +1388,10 @@ function constructField(name, parameters, options) { | ||||
|     html += `</div>`;   // controls | ||||
|     html += `</div>`;   // form-group | ||||
|      | ||||
|     if (parameters.after) { | ||||
|         html += parameters.after; | ||||
|     } | ||||
|  | ||||
|     return html; | ||||
| } | ||||
|  | ||||
| @@ -1430,6 +1470,9 @@ function constructInput(name, parameters, options) { | ||||
|         case 'date': | ||||
|             func = constructDateInput; | ||||
|             break; | ||||
|         case 'candy': | ||||
|             func = constructCandyInput; | ||||
|             break; | ||||
|         default: | ||||
|             // Unsupported field type! | ||||
|             break; | ||||
| @@ -1658,6 +1701,17 @@ function constructDateInput(name, parameters, options) { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Construct a "candy" field input | ||||
|  * No actual field data! | ||||
|  */ | ||||
| function constructCandyInput(name, parameters, options) { | ||||
|  | ||||
|     return parameters.html; | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Construct a 'help text' div based on the field parameters | ||||
|  *  | ||||
|   | ||||
| @@ -13,91 +13,213 @@ function yesNoLabel(value) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Construct fieldset for part forms | ||||
| function partFields(options={}) { | ||||
|  | ||||
|     var fields = { | ||||
|         category: {}, | ||||
|         name: {}, | ||||
|         IPN: {}, | ||||
|         revision: {}, | ||||
|         description: {}, | ||||
|         variant_of: {}, | ||||
|         keywords: { | ||||
|             icon: 'fa-key', | ||||
|         }, | ||||
|         units: {}, | ||||
|         link: { | ||||
|             icon: 'fa-link', | ||||
|         }, | ||||
|         default_location: {}, | ||||
|         default_supplier: {}, | ||||
|         default_expiry: { | ||||
|             icon: 'fa-calendar-alt', | ||||
|         }, | ||||
|         minimum_stock: { | ||||
|             icon: 'fa-boxes', | ||||
|         }, | ||||
|         attributes: { | ||||
|             type: 'candy', | ||||
|             html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>` | ||||
|         }, | ||||
|         component: { | ||||
|             value: global_settings.PART_COMPONENT, | ||||
|         }, | ||||
|         assembly: { | ||||
|             value: global_settings.PART_ASSEMBLY, | ||||
|         }, | ||||
|         is_template: { | ||||
|             value: global_settings.PART_TEMPLATE, | ||||
|         }, | ||||
|         trackable: { | ||||
|             value: global_settings.PART_TRACKABLE, | ||||
|         }, | ||||
|         purchaseable: { | ||||
|             value: global_settings.PART_PURCHASEABLE, | ||||
|         }, | ||||
|         salable: { | ||||
|             value: global_settings.PART_SALABLE, | ||||
|         }, | ||||
|         virtual: { | ||||
|             value: global_settings.PART_VIRTUAL, | ||||
|         }, | ||||
|     }; | ||||
|  | ||||
|     // If editing a part, we can set the "active" status | ||||
|     if (options.edit) { | ||||
|         fields.active = {}; | ||||
|     } | ||||
|  | ||||
|     // Pop expiry field | ||||
|     if (!global_settings.STOCK_ENABLE_EXPIRY) { | ||||
|         delete fields["default_expiry"]; | ||||
|     } | ||||
|  | ||||
|     // Additional fields when "creating" a new part | ||||
|     if (options.create) { | ||||
|  | ||||
|         // No supplier parts available yet | ||||
|         delete fields["default_supplier"]; | ||||
|  | ||||
|         fields.create = { | ||||
|             type: 'candy', | ||||
|             html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`, | ||||
|         }; | ||||
|  | ||||
|         if (global_settings.PART_CREATE_INITIAL) { | ||||
|             fields.initial_stock = { | ||||
|                 type: 'decimal', | ||||
|                 label: '{% trans "Initial Stock Quantity" %}', | ||||
|                 help_text: '{% trans "Initialize part stock with specified quantity" %}', | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         fields.copy_category_parameters = { | ||||
|             type: 'boolean', | ||||
|             label: '{% trans "Copy Category Parameters" %}', | ||||
|             help_text: '{% trans "Copy parameter templates from selected part category" %}', | ||||
|             value: global_settings.PART_CATEGORY_PARAMETERS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     // Additional fields when "duplicating" a part | ||||
|     if (options.duplicate) { | ||||
|  | ||||
|         fields.duplicate = { | ||||
|             type: 'candy', | ||||
|             html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`, | ||||
|         }; | ||||
|  | ||||
|         fields.copy_from = { | ||||
|             type: 'integer', | ||||
|             hidden: true, | ||||
|             value: options.duplicate, | ||||
|         }, | ||||
|  | ||||
|         fields.copy_image = { | ||||
|             type: 'boolean', | ||||
|             label: '{% trans "Copy Image" %}', | ||||
|             help_text: '{% trans "Copy image from original part" %}', | ||||
|             value: true, | ||||
|         }, | ||||
|  | ||||
|         fields.copy_bom = { | ||||
|             type: 'boolean', | ||||
|             label: '{% trans "Copy BOM" %}', | ||||
|             help_text: '{% trans "Copy bill of materials from original part" %}', | ||||
|             value: global_settings.PART_COPY_BOM, | ||||
|         }; | ||||
|  | ||||
|         fields.copy_parameters = { | ||||
|             type: 'boolean', | ||||
|             label: '{% trans "Copy Parameters" %}', | ||||
|             help_text: '{% trans "Copy parameter data from original part" %}', | ||||
|             value: global_settings.PART_COPY_PARAMETERS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     return fields; | ||||
| } | ||||
|  | ||||
|  | ||||
| function categoryFields() { | ||||
|     return { | ||||
|         parent: { | ||||
|             help_text: '{% trans "Parent part category" %}', | ||||
|         }, | ||||
|         name: {}, | ||||
|         description: {}, | ||||
|         default_location: {}, | ||||
|         default_keywords: { | ||||
|             icon: 'fa-key', | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
|  | ||||
| // Edit a PartCategory via the API | ||||
| function editCategory(pk, options={}) { | ||||
|  | ||||
|     var url = `/api/part/category/${pk}/`; | ||||
|  | ||||
|     var fields = categoryFields(); | ||||
|  | ||||
|     constructForm(url, { | ||||
|         fields: fields, | ||||
|         title: '{% trans "Edit Part Category" %}', | ||||
|         reload: true, | ||||
|     }); | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| function editPart(pk, options={}) { | ||||
|  | ||||
|     var url = `/api/part/${pk}/`; | ||||
|  | ||||
|     var fields =  { | ||||
|         category: { | ||||
|             /* | ||||
|             secondary: { | ||||
|                 label: '{% trans "New Category" %}', | ||||
|                 title: '{% trans "Create New Part Category" %}', | ||||
|                 api_url: '{% url "api-part-category-list" %}', | ||||
|                 method: 'POST', | ||||
|                 fields: { | ||||
|                     name: {}, | ||||
|                     description: {}, | ||||
|                     parent: { | ||||
|                         secondary: { | ||||
|                             title: '{% trans "New Parent" %}', | ||||
|                             api_url: '{% url "api-part-category-list" %}', | ||||
|                             method: 'POST', | ||||
|                             fields: { | ||||
|                                 name: {}, | ||||
|                                 description: {}, | ||||
|                                 parent: {}, | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                 } | ||||
|             }, | ||||
|             */ | ||||
|         }, | ||||
|         name: { | ||||
|             placeholder: 'part name', | ||||
|         }, | ||||
|         IPN: {}, | ||||
|         description: {}, | ||||
|         revision: {}, | ||||
|         keywords: { | ||||
|             icon: 'fa-key', | ||||
|         }, | ||||
|         variant_of: {}, | ||||
|         link: { | ||||
|             icon: 'fa-link', | ||||
|         }, | ||||
|         default_location: { | ||||
|             /* | ||||
|             secondary: { | ||||
|                 label: '{% trans "New Location" %}', | ||||
|                 title: '{% trans "Create new stock location" %}', | ||||
|             }, | ||||
|             */ | ||||
|         }, | ||||
|         default_supplier: { | ||||
|             filters: { | ||||
|                 part: pk, | ||||
|                 part_detail: true, | ||||
|                 manufacturer_detail: true, | ||||
|                 supplier_detail: true, | ||||
|             }, | ||||
|             /* | ||||
|             secondary: { | ||||
|                 label: '{% trans "New Supplier Part" %}', | ||||
|                 title: '{% trans "Create new supplier part" %}', | ||||
|             } | ||||
|             */ | ||||
|         }, | ||||
|         units: {}, | ||||
|         minimum_stock: {}, | ||||
|         virtual: {}, | ||||
|         is_template: {}, | ||||
|         assembly: {}, | ||||
|         component: {}, | ||||
|         trackable: {}, | ||||
|         purchaseable: {}, | ||||
|         salable: {}, | ||||
|         active: {}, | ||||
|     }; | ||||
|     var fields = partFields({ | ||||
|         edit: true | ||||
|     }); | ||||
|  | ||||
|     constructForm(url, { | ||||
|         fields: fields, | ||||
|         title: '{% trans "Edit Part" %}', | ||||
|         reload: true, | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| // Launch form to duplicate a part | ||||
| function duplicatePart(pk, options={}) { | ||||
|  | ||||
|     // First we need all the part information | ||||
|     inventreeGet(`/api/part/${pk}/`, {}, { | ||||
|  | ||||
|         success: function(data) { | ||||
|              | ||||
|             var fields = partFields({ | ||||
|                 duplicate: pk, | ||||
|             }); | ||||
|  | ||||
|             // If we are making a "variant" part | ||||
|             if (options.variant) { | ||||
|  | ||||
|                 // Override the "variant_of" field | ||||
|                 data.variant_of = pk; | ||||
|             } | ||||
|              | ||||
|             constructForm('{% url "api-part-list" %}', { | ||||
|                 method: 'POST', | ||||
|                 fields: fields, | ||||
|                 title: '{% trans "Duplicate Part" %}', | ||||
|                 data: data, | ||||
|                 onSuccess: function(data) { | ||||
|                     // Follow the new part | ||||
|                     location.href = `/part/${data.pk}/`; | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -187,7 +187,7 @@ $.fn.inventreeTable = function(options) { | ||||
|     if (!options.disablePagination) { | ||||
|         options.pagination = true; | ||||
|         options.paginationVAlign = options.paginationVAlign || 'both'; | ||||
|         options.pageSize = inventreeLoad(varName, 25); | ||||
|         options.pageSize = options.pageSize || inventreeLoad(varName, 25); | ||||
|         options.pageList = [25, 50, 100, 250, 'all']; | ||||
|         options.totalField = 'count'; | ||||
|         options.dataField = 'results'; | ||||
|   | ||||
| @@ -21,7 +21,8 @@ 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 | ||||
| django-weasyprint==1.0.1        # HTML PDF export | ||||
| weasyprint==52.5                # PDF generation library (Note: in the future need to update to 53) | ||||
| django-weasyprint==1.0.1        # django weasyprint integration | ||||
| django-debug-toolbar==2.2       # Debug / profiling toolbar | ||||
| django-admin-shell==0.1.2       # Python shell for the admin interface | ||||
| py-moneyed==0.8.0               # Specific version requirement for py-moneyed | ||||
|   | ||||
		Reference in New Issue
	
	Block a user