mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		| @@ -153,6 +153,11 @@ class InvenTreeMetadata(SimpleMetadata): | ||||
|         if 'default' not in field_info and not field.default == empty: | ||||
|             field_info['default'] = field.get_default() | ||||
|  | ||||
|         # Force non-nullable fields to read as "required" | ||||
|         # (even if there is a default value!) | ||||
|         if not field.allow_null and not (hasattr(field, 'allow_blank') and field.allow_blank): | ||||
|             field_info['required'] = True | ||||
|  | ||||
|         # Introspect writable related fields | ||||
|         if field_info['type'] == 'field' and not field_info['read_only']: | ||||
|              | ||||
| @@ -166,7 +171,12 @@ class InvenTreeMetadata(SimpleMetadata): | ||||
|             if model: | ||||
|                 # Mark this field as "related", and point to the URL where we can get the data! | ||||
|                 field_info['type'] = 'related field' | ||||
|                 field_info['api_url'] = model.get_api_url() | ||||
|                 field_info['model'] = model._meta.model_name | ||||
|  | ||||
|                 # Special case for 'user' model | ||||
|                 if field_info['model'] == 'user': | ||||
|                     field_info['api_url'] = '/api/user/' | ||||
|                 else: | ||||
|                     field_info['api_url'] = model.get_api_url() | ||||
|  | ||||
|         return field_info | ||||
|   | ||||
| @@ -5,11 +5,13 @@ JSON API for the Build app | ||||
| # -*- coding: utf-8 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from django.conf.urls import url, include | ||||
|  | ||||
| from rest_framework import filters | ||||
| from rest_framework import generics | ||||
|  | ||||
| from django.conf.urls import url, include | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from django_filters import rest_framework as rest_filters | ||||
|  | ||||
| from InvenTree.api import AttachmentMixin | ||||
| from InvenTree.helpers import str2bool, isNull | ||||
| @@ -19,6 +21,36 @@ from .models import Build, BuildItem, BuildOrderAttachment | ||||
| from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer | ||||
|  | ||||
|  | ||||
| class BuildFilter(rest_filters.FilterSet): | ||||
|     """ | ||||
|     Custom filterset for BuildList API endpoint | ||||
|     """ | ||||
|  | ||||
|     status = rest_filters.NumberFilter(label='Status') | ||||
|  | ||||
|     active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') | ||||
|  | ||||
|     def filter_active(self, queryset, name, value): | ||||
|  | ||||
|         if str2bool(value): | ||||
|             queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES) | ||||
|         else: | ||||
|             queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue') | ||||
|  | ||||
|     def filter_overdue(self, queryset, name, value): | ||||
|  | ||||
|         if str2bool(value): | ||||
|             queryset = queryset.filter(Build.OVERDUE_FILTER) | ||||
|         else: | ||||
|             queryset = queryset.exclude(Build.OVERDUE_FILTER) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|  | ||||
| class BuildList(generics.ListCreateAPIView): | ||||
|     """ API endpoint for accessing a list of Build objects. | ||||
|  | ||||
| @@ -28,6 +60,7 @@ class BuildList(generics.ListCreateAPIView): | ||||
|  | ||||
|     queryset = Build.objects.all() | ||||
|     serializer_class = BuildSerializer | ||||
|     filterset_class = BuildFilter | ||||
|  | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
| @@ -97,34 +130,6 @@ class BuildList(generics.ListCreateAPIView): | ||||
|             except (ValueError, Build.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|         # Filter by build status? | ||||
|         status = params.get('status', None) | ||||
|  | ||||
|         if status is not None: | ||||
|             queryset = queryset.filter(status=status) | ||||
|  | ||||
|         # Filter by "pending" status | ||||
|         active = params.get('active', None) | ||||
|  | ||||
|         if active is not None: | ||||
|             active = str2bool(active) | ||||
|  | ||||
|             if active: | ||||
|                 queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES) | ||||
|             else: | ||||
|                 queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES) | ||||
|  | ||||
|         # Filter by "overdue" status? | ||||
|         overdue = params.get('overdue', None) | ||||
|  | ||||
|         if overdue is not None: | ||||
|             overdue = str2bool(overdue) | ||||
|  | ||||
|             if overdue: | ||||
|                 queryset = queryset.filter(Build.OVERDUE_FILTER) | ||||
|             else: | ||||
|                 queryset = queryset.exclude(Build.OVERDUE_FILTER) | ||||
|  | ||||
|         # Filter by associated part? | ||||
|         part = params.get('part', None) | ||||
|  | ||||
|   | ||||
							
								
								
									
										20
									
								
								InvenTree/build/migrations/0030_alter_build_reference.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								InvenTree/build/migrations/0030_alter_build_reference.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| # Generated by Django 3.2.4 on 2021-07-08 14:14 | ||||
|  | ||||
| import InvenTree.validators | ||||
| import build.models | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('build', '0029_auto_20210601_1525'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='build', | ||||
|             name='reference', | ||||
|             field=models.CharField(default=build.models.get_next_build_number, help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -21,6 +21,7 @@ from django.core.validators import MinValueValidator | ||||
| from markdownx.models import MarkdownxField | ||||
|  | ||||
| from mptt.models import MPTTModel, TreeForeignKey | ||||
| from mptt.exceptions import InvalidMove | ||||
|  | ||||
| from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode | ||||
| from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode | ||||
| @@ -37,6 +38,35 @@ from part import models as PartModels | ||||
| from users import models as UserModels | ||||
|  | ||||
|  | ||||
| def get_next_build_number(): | ||||
|     """ | ||||
|     Returns the next available BuildOrder reference number | ||||
|     """ | ||||
|  | ||||
|     if Build.objects.count() == 0: | ||||
|         return | ||||
|  | ||||
|     build = Build.objects.exclude(reference=None).last() | ||||
|  | ||||
|     attempts = set([build.reference]) | ||||
|  | ||||
|     reference = build.reference | ||||
|  | ||||
|     while 1: | ||||
|         reference = increment(reference) | ||||
|  | ||||
|         if reference in attempts: | ||||
|             # Escape infinite recursion | ||||
|             return reference | ||||
|  | ||||
|         if Build.objects.filter(reference=reference).exists(): | ||||
|             attempts.add(reference) | ||||
|         else: | ||||
|             break | ||||
|      | ||||
|     return reference | ||||
|  | ||||
|  | ||||
| class Build(MPTTModel): | ||||
|     """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. | ||||
|  | ||||
| @@ -60,11 +90,20 @@ class Build(MPTTModel): | ||||
|         responsible: User (or group) responsible for completing the build | ||||
|     """ | ||||
|  | ||||
|     OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) | ||||
|      | ||||
|     @staticmethod | ||||
|     def get_api_url(): | ||||
|         return reverse('api-build-list') | ||||
|  | ||||
|     OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) | ||||
|     def save(self, *args, **kwargs): | ||||
|  | ||||
|         try: | ||||
|             super().save(*args, **kwargs) | ||||
|         except InvalidMove: | ||||
|             raise ValidationError({ | ||||
|                 'parent': _('Invalid choice for parent build'), | ||||
|             }) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Build Order") | ||||
| @@ -130,6 +169,7 @@ class Build(MPTTModel): | ||||
|         blank=False, | ||||
|         help_text=_('Build Order Reference'), | ||||
|         verbose_name=_('Reference'), | ||||
|         default=get_next_build_number, | ||||
|         validators=[ | ||||
|             validate_build_order_reference | ||||
|         ] | ||||
|   | ||||
| @@ -62,7 +62,7 @@ class BuildSerializer(InvenTreeModelSerializer): | ||||
|         return queryset | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         part_detail = kwargs.pop('part_detail', False) | ||||
|         part_detail = kwargs.pop('part_detail', True) | ||||
|  | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
| @@ -75,9 +75,12 @@ class BuildSerializer(InvenTreeModelSerializer): | ||||
|             'pk', | ||||
|             'url', | ||||
|             'title', | ||||
|             'batch', | ||||
|             'creation_date', | ||||
|             'completed', | ||||
|             'completion_date', | ||||
|             'destination', | ||||
|             'parent', | ||||
|             'part', | ||||
|             'part_detail', | ||||
|             'overdue', | ||||
| @@ -87,6 +90,7 @@ class BuildSerializer(InvenTreeModelSerializer): | ||||
|             'status', | ||||
|             'status_text', | ||||
|             'target_date', | ||||
|             'take_from', | ||||
|             'notes', | ||||
|             'link', | ||||
|             'issued_by', | ||||
|   | ||||
| @@ -196,10 +196,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
|     }); | ||||
|  | ||||
|     $("#build-edit").click(function () { | ||||
|         launchModalForm("{% url 'build-edit' build.id %}", | ||||
|                         { | ||||
|                             reload: true | ||||
|                         }); | ||||
|         editBuildOrder({{ build.pk }}); | ||||
|     }); | ||||
|  | ||||
|     $("#build-cancel").click(function() { | ||||
|   | ||||
| @@ -5,10 +5,11 @@ from django.test import TestCase | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.db.utils import IntegrityError | ||||
|  | ||||
| from build.models import Build, BuildItem | ||||
| from InvenTree import status_codes as status | ||||
|  | ||||
| from build.models import Build, BuildItem, get_next_build_number | ||||
| from stock.models import StockItem | ||||
| from part.models import Part, BomItem | ||||
| from InvenTree import status_codes as status | ||||
|  | ||||
|  | ||||
| class BuildTest(TestCase): | ||||
| @@ -80,8 +81,14 @@ class BuildTest(TestCase): | ||||
|             quantity=2 | ||||
|         ) | ||||
|  | ||||
|         ref = get_next_build_number() | ||||
|  | ||||
|         if ref is None: | ||||
|             ref = "0001" | ||||
|  | ||||
|         # Create a "Build" object to make 10x objects | ||||
|         self.build = Build.objects.create( | ||||
|             reference=ref, | ||||
|             title="This is a build", | ||||
|             part=self.assembly, | ||||
|             quantity=10 | ||||
|   | ||||
| @@ -252,23 +252,6 @@ class TestBuildViews(TestCase): | ||||
|  | ||||
|         self.assertIn(build.title, content) | ||||
|  | ||||
|     def test_build_create(self): | ||||
|         """ Test the build creation view (ajax form) """ | ||||
|  | ||||
|         url = reverse('build-create') | ||||
|  | ||||
|         # Create build without specifying part | ||||
|         response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # Create build with valid part | ||||
|         response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # Create build with invalid part | ||||
|         response = self.client.get(url, {'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_build_allocate(self): | ||||
|         """ Test the part allocation view for a Build """ | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,6 @@ from django.conf.urls import url, include | ||||
| from . import views | ||||
|  | ||||
| build_detail_urls = [ | ||||
|     url(r'^edit/', views.BuildUpdate.as_view(), name='build-edit'), | ||||
|     url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'), | ||||
|     url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), | ||||
|     url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), | ||||
| @@ -36,8 +35,6 @@ build_urls = [ | ||||
|         url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), | ||||
|     ])), | ||||
|  | ||||
|     url(r'new/', views.BuildCreate.as_view(), name='build-create'), | ||||
|  | ||||
|     url(r'^(?P<pk>\d+)/', include(build_detail_urls)), | ||||
|  | ||||
|     url(r'.*$', views.BuildIndex.as_view(), name='build-index'), | ||||
|   | ||||
| @@ -667,126 +667,6 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView): | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class BuildCreate(AjaxCreateView): | ||||
|     """ | ||||
|     View to create a new Build object | ||||
|     """ | ||||
|  | ||||
|     model = Build | ||||
|     context_object_name = 'build' | ||||
|     form_class = forms.EditBuildForm | ||||
|     ajax_form_title = _('New Build Order') | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|  | ||||
|     def get_form(self): | ||||
|         form = super().get_form() | ||||
|  | ||||
|         if form['part'].value(): | ||||
|             form.fields['part'].widget = HiddenInput() | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def get_initial(self): | ||||
|         """ Get initial parameters for Build creation. | ||||
|  | ||||
|         If 'part' is specified in the GET query, initialize the Build with the specified Part | ||||
|         """ | ||||
|  | ||||
|         initials = super(BuildCreate, self).get_initial().copy() | ||||
|  | ||||
|         initials['parent'] = self.request.GET.get('parent', None) | ||||
|  | ||||
|         # User has provided a SalesOrder ID | ||||
|         initials['sales_order'] = self.request.GET.get('sales_order', None) | ||||
|  | ||||
|         initials['quantity'] = self.request.GET.get('quantity', 1) | ||||
|  | ||||
|         part = self.request.GET.get('part', None) | ||||
|  | ||||
|         if part: | ||||
|  | ||||
|             try: | ||||
|                 part = Part.objects.get(pk=part) | ||||
|                 # User has provided a Part ID | ||||
|                 initials['part'] = part | ||||
|                 initials['destination'] = part.get_default_location() | ||||
|  | ||||
|                 to_order = part.quantity_to_order | ||||
|  | ||||
|                 if to_order < 1: | ||||
|                     to_order = 1 | ||||
|  | ||||
|                 initials['quantity'] = to_order | ||||
|             except (ValueError, Part.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|         initials['reference'] = Build.getNextBuildNumber() | ||||
|  | ||||
|         # Pre-fill the issued_by user | ||||
|         initials['issued_by'] = self.request.user | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|     def get_data(self): | ||||
|         return { | ||||
|             'success': _('Created new build'), | ||||
|         } | ||||
|  | ||||
|     def validate(self, build, form, **kwargs): | ||||
|         """ | ||||
|         Perform extra form validation. | ||||
|  | ||||
|         - If part is trackable, check that either batch or serial numbers are calculated | ||||
|  | ||||
|         By this point form.is_valid() has been executed | ||||
|         """ | ||||
|  | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class BuildUpdate(AjaxUpdateView): | ||||
|     """ View for editing a Build object """ | ||||
|  | ||||
|     model = Build | ||||
|     form_class = forms.EditBuildForm | ||||
|     context_object_name = 'build' | ||||
|     ajax_form_title = _('Edit Build Order Details') | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|  | ||||
|     def get_form(self): | ||||
|  | ||||
|         form = super().get_form() | ||||
|  | ||||
|         build = self.get_object() | ||||
|  | ||||
|         # Fields which are included in the form, but hidden | ||||
|         hidden = [ | ||||
|             'parent', | ||||
|             'sales_order', | ||||
|         ] | ||||
|  | ||||
|         if build.is_complete: | ||||
|             # Fields which cannot be edited once the build has been completed | ||||
|  | ||||
|             hidden += [ | ||||
|                 'part', | ||||
|                 'quantity', | ||||
|                 'batch', | ||||
|                 'take_from', | ||||
|                 'destination', | ||||
|             ] | ||||
|  | ||||
|         for field in hidden: | ||||
|             form.fields[field].widget = HiddenInput() | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def get_data(self): | ||||
|         return { | ||||
|             'info': _('Edited build'), | ||||
|         } | ||||
|  | ||||
|  | ||||
| class BuildDelete(AjaxDeleteView): | ||||
|     """ View to delete a build """ | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ from __future__ import unicode_literals | ||||
|  | ||||
| from django.contrib import admin | ||||
|  | ||||
| from .models import StockItemLabel, StockLocationLabel | ||||
| from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||
|  | ||||
|  | ||||
| class LabelAdmin(admin.ModelAdmin): | ||||
| @@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin): | ||||
|  | ||||
| admin.site.register(StockItemLabel, LabelAdmin) | ||||
| admin.site.register(StockLocationLabel, LabelAdmin) | ||||
| admin.site.register(PartLabel, LabelAdmin) | ||||
|   | ||||
| @@ -15,9 +15,10 @@ import InvenTree.helpers | ||||
| import common.models | ||||
|  | ||||
| from stock.models import StockItem, StockLocation | ||||
| from part.models import Part | ||||
|  | ||||
| from .models import StockItemLabel, StockLocationLabel | ||||
| from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer | ||||
| from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||
| from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer | ||||
|  | ||||
|  | ||||
| class LabelListView(generics.ListAPIView): | ||||
| @@ -132,6 +133,7 @@ class StockItemLabelMixin: | ||||
|         for key in ['item', 'item[]', 'items', 'items[]']: | ||||
|             if key in params: | ||||
|                 items = params.getlist(key, []) | ||||
|                 break | ||||
|  | ||||
|         valid_ids = [] | ||||
|  | ||||
| @@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, | ||||
|         return self.print(request, locations) | ||||
|  | ||||
|  | ||||
| class PartLabelMixin: | ||||
|     """ | ||||
|     Mixin for extracting Part objects from query parameters | ||||
|     """ | ||||
|  | ||||
|     def get_parts(self): | ||||
|         """ | ||||
|         Return a list of requested Part objects | ||||
|         """ | ||||
|  | ||||
|         parts = [] | ||||
|  | ||||
|         params = self.request.query_params | ||||
|  | ||||
|         for key in ['part', 'part[]', 'parts', 'parts[]']: | ||||
|             if key in params: | ||||
|                 parts = params.getlist(key, []) | ||||
|                 break | ||||
|                  | ||||
|         valid_ids = [] | ||||
|  | ||||
|         for part in parts: | ||||
|             try: | ||||
|                 valid_ids.append(int(part)) | ||||
|             except (ValueError): | ||||
|                 pass | ||||
|  | ||||
|         # List of Part objects which match provided values | ||||
|         return Part.objects.filter(pk__in=valid_ids) | ||||
|  | ||||
|  | ||||
| class PartLabelList(LabelListView, PartLabelMixin): | ||||
|     """ | ||||
|     API endpoint for viewing list of PartLabel objects | ||||
|     """ | ||||
|  | ||||
|     queryset = PartLabel.objects.all() | ||||
|     serializer_class = PartLabelSerializer | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|  | ||||
|         queryset = super().filter_queryset(queryset) | ||||
|  | ||||
|         parts = self.get_parts() | ||||
|  | ||||
|         if len(parts) > 0: | ||||
|  | ||||
|             valid_label_ids = set() | ||||
|  | ||||
|             for label in queryset.all(): | ||||
|  | ||||
|                 matches = True | ||||
|  | ||||
|                 try: | ||||
|                     filters = InvenTree.helpers.validateFilterString(label.filters) | ||||
|                 except ValidationError: | ||||
|                     continue | ||||
|  | ||||
|                 for part in parts: | ||||
|  | ||||
|                     part_query = Part.objects.filter(pk=part.pk) | ||||
|  | ||||
|                     try: | ||||
|                         if not part_query.filter(**filters).exists(): | ||||
|                             matches = False | ||||
|                             break | ||||
|                     except FieldError: | ||||
|                         matches = False | ||||
|                         break | ||||
|  | ||||
|                 if matches: | ||||
|                     valid_label_ids.add(label.pk) | ||||
|  | ||||
|             # Reduce queryset to only valid matches | ||||
|             queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids]) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|  | ||||
| class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView): | ||||
|     """ | ||||
|     API endpoint for a single PartLabel object | ||||
|     """ | ||||
|  | ||||
|     queryset = PartLabel.objects.all() | ||||
|     serializer_class = PartLabelSerializer | ||||
|  | ||||
|  | ||||
| class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin): | ||||
|     """ | ||||
|     API endpoint for printing a PartLabel object | ||||
|     """ | ||||
|  | ||||
|     queryset = PartLabel.objects.all() | ||||
|     serializer_class = PartLabelSerializer | ||||
|  | ||||
|     def get(self, request, *args, **kwargs): | ||||
|         """ | ||||
|         Check if valid part(s) have been provided | ||||
|         """ | ||||
|  | ||||
|         parts = self.get_parts() | ||||
|  | ||||
|         return self.print(request, parts) | ||||
|  | ||||
|  | ||||
| label_api_urls = [ | ||||
|  | ||||
|     # Stock item labels | ||||
| @@ -401,4 +509,16 @@ label_api_urls = [ | ||||
|         # List view | ||||
|         url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'), | ||||
|     ])), | ||||
|  | ||||
|     # Part labels | ||||
|     url(r'^part/', include([ | ||||
|         # Detail views | ||||
|         url(r'^(?P<pk>\d+)/', include([ | ||||
|             url(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), | ||||
|             url(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'), | ||||
|         ])), | ||||
|  | ||||
|         # List view | ||||
|         url(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'), | ||||
|     ])), | ||||
| ] | ||||
|   | ||||
| @@ -37,6 +37,7 @@ class LabelConfig(AppConfig): | ||||
|         if canAppAccessDatabase(): | ||||
|             self.create_stock_item_labels() | ||||
|             self.create_stock_location_labels() | ||||
|             self.create_part_labels() | ||||
|  | ||||
|     def create_stock_item_labels(self): | ||||
|         """ | ||||
| @@ -65,7 +66,7 @@ class LabelConfig(AppConfig): | ||||
|         ) | ||||
|  | ||||
|         if not os.path.exists(dst_dir): | ||||
|             logger.info(f"Creating missing directory: '{dst_dir}'") | ||||
|             logger.info(f"Creating required directory: '{dst_dir}'") | ||||
|             os.makedirs(dst_dir, exist_ok=True) | ||||
|  | ||||
|         labels = [ | ||||
| @@ -109,24 +110,21 @@ class LabelConfig(AppConfig): | ||||
|                 logger.info(f"Copying label template '{dst_file}'") | ||||
|                 shutil.copyfile(src_file, dst_file) | ||||
|  | ||||
|             try: | ||||
|                 # Check if a label matching the template already exists | ||||
|                 if StockItemLabel.objects.filter(label=filename).exists(): | ||||
|                     continue | ||||
|             # Check if a label matching the template already exists | ||||
|             if StockItemLabel.objects.filter(label=filename).exists(): | ||||
|                 continue | ||||
|  | ||||
|                 logger.info(f"Creating entry for StockItemLabel '{label['name']}'") | ||||
|             logger.info(f"Creating entry for StockItemLabel '{label['name']}'") | ||||
|  | ||||
|                 StockItemLabel.objects.create( | ||||
|                     name=label['name'], | ||||
|                     description=label['description'], | ||||
|                     label=filename, | ||||
|                     filters='', | ||||
|                     enabled=True, | ||||
|                     width=label['width'], | ||||
|                     height=label['height'], | ||||
|                 ) | ||||
|             except: | ||||
|                 pass | ||||
|             StockItemLabel.objects.create( | ||||
|                 name=label['name'], | ||||
|                 description=label['description'], | ||||
|                 label=filename, | ||||
|                 filters='', | ||||
|                 enabled=True, | ||||
|                 width=label['width'], | ||||
|                 height=label['height'], | ||||
|             ) | ||||
|  | ||||
|     def create_stock_location_labels(self): | ||||
|         """ | ||||
| @@ -155,7 +153,7 @@ class LabelConfig(AppConfig): | ||||
|         ) | ||||
|  | ||||
|         if not os.path.exists(dst_dir): | ||||
|             logger.info(f"Creating missing directory: '{dst_dir}'") | ||||
|             logger.info(f"Creating required directory: '{dst_dir}'") | ||||
|             os.makedirs(dst_dir, exist_ok=True) | ||||
|  | ||||
|         labels = [ | ||||
| @@ -206,21 +204,103 @@ class LabelConfig(AppConfig): | ||||
|                 logger.info(f"Copying label template '{dst_file}'") | ||||
|                 shutil.copyfile(src_file, dst_file) | ||||
|  | ||||
|             try: | ||||
|                 # Check if a label matching the template already exists | ||||
|                 if StockLocationLabel.objects.filter(label=filename).exists(): | ||||
|                     continue | ||||
|             # Check if a label matching the template already exists | ||||
|             if StockLocationLabel.objects.filter(label=filename).exists(): | ||||
|                 continue | ||||
|  | ||||
|                 logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") | ||||
|             logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") | ||||
|  | ||||
|                 StockLocationLabel.objects.create( | ||||
|                     name=label['name'], | ||||
|                     description=label['description'], | ||||
|                     label=filename, | ||||
|                     filters='', | ||||
|                     enabled=True, | ||||
|                     width=label['width'], | ||||
|                     height=label['height'], | ||||
|                 ) | ||||
|             except: | ||||
|                 pass | ||||
|             StockLocationLabel.objects.create( | ||||
|                 name=label['name'], | ||||
|                 description=label['description'], | ||||
|                 label=filename, | ||||
|                 filters='', | ||||
|                 enabled=True, | ||||
|                 width=label['width'], | ||||
|                 height=label['height'], | ||||
|             ) | ||||
|  | ||||
|     def create_part_labels(self): | ||||
|         """ | ||||
|         Create database entries for the default PartLabel templates, | ||||
|         if they do not already exist. | ||||
|         """ | ||||
|  | ||||
|         try: | ||||
|             from .models import PartLabel | ||||
|         except: | ||||
|             # Database might not yet be ready | ||||
|             return | ||||
|  | ||||
|         src_dir = os.path.join( | ||||
|             os.path.dirname(os.path.realpath(__file__)), | ||||
|             'templates', | ||||
|             'label', | ||||
|             'part', | ||||
|         ) | ||||
|  | ||||
|         dst_dir = os.path.join( | ||||
|             settings.MEDIA_ROOT, | ||||
|             'label', | ||||
|             'inventree', | ||||
|             'part', | ||||
|         ) | ||||
|  | ||||
|         if not os.path.exists(dst_dir): | ||||
|             logger.info(f"Creating required directory: '{dst_dir}'") | ||||
|             os.makedirs(dst_dir, exist_ok=True) | ||||
|  | ||||
|         labels = [ | ||||
|             { | ||||
|                 'file': 'part_label.html', | ||||
|                 'name': 'Part Label', | ||||
|                 'description': 'Simple part label', | ||||
|                 'width': 70, | ||||
|                 'height': 24, | ||||
|             }, | ||||
|         ] | ||||
|  | ||||
|         for label in labels: | ||||
|  | ||||
|             filename = os.path.join( | ||||
|                 'label', | ||||
|                 'inventree', | ||||
|                 'part', | ||||
|                 label['file'] | ||||
|             ) | ||||
|  | ||||
|             src_file = os.path.join(src_dir, label['file']) | ||||
|             dst_file = os.path.join(settings.MEDIA_ROOT, filename) | ||||
|  | ||||
|             to_copy = False | ||||
|  | ||||
|             if os.path.exists(dst_file): | ||||
|                 # File already exists - let's see if it is the "same" | ||||
|  | ||||
|                 if not hashFile(dst_file) == hashFile(src_file): | ||||
|                     logger.info(f"Hash differs for '{filename}'") | ||||
|                     to_copy = True | ||||
|  | ||||
|             else: | ||||
|                 logger.info(f"Label template '{filename}' is not present") | ||||
|                 to_copy = True | ||||
|  | ||||
|             if to_copy: | ||||
|                 logger.info(f"Copying label template '{dst_file}'") | ||||
|                 shutil.copyfile(src_file, dst_file) | ||||
|  | ||||
|             # Check if a label matching the template already exists | ||||
|             if PartLabel.objects.filter(label=filename).exists(): | ||||
|                 continue | ||||
|  | ||||
|             logger.info(f"Creating entry for PartLabel '{label['name']}'") | ||||
|  | ||||
|             PartLabel.objects.create( | ||||
|                 name=label['name'], | ||||
|                 description=label['description'], | ||||
|                 label=filename, | ||||
|                 filters='', | ||||
|                 enabled=True, | ||||
|                 width=label['width'], | ||||
|                 height=label['height'], | ||||
|             ) | ||||
|   | ||||
							
								
								
									
										37
									
								
								InvenTree/label/migrations/0008_auto_20210708_2106.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								InvenTree/label/migrations/0008_auto_20210708_2106.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| # Generated by Django 3.2.4 on 2021-07-08 11:06 | ||||
|  | ||||
| import django.core.validators | ||||
| from django.db import migrations, models | ||||
| import label.models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('label', '0007_auto_20210513_1327'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='PartLabel', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')), | ||||
|                 ('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')), | ||||
|                 ('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')), | ||||
|                 ('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')), | ||||
|                 ('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')), | ||||
|                 ('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')), | ||||
|                 ('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')), | ||||
|                 ('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'abstract': False, | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='stockitemlabel', | ||||
|             name='filters', | ||||
|             field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -25,6 +25,8 @@ from InvenTree.helpers import validateFilterString, normalize | ||||
|  | ||||
| import common.models | ||||
| import stock.models | ||||
| import part.models | ||||
|  | ||||
|  | ||||
| try: | ||||
|     from django_weasyprint import WeasyTemplateResponseMixin | ||||
| @@ -59,6 +61,13 @@ def validate_stock_location_filters(filters): | ||||
|     return filters | ||||
|  | ||||
|  | ||||
| def validate_part_filters(filters): | ||||
|  | ||||
|     filters = validateFilterString(filters, model=part.models.Part) | ||||
|  | ||||
|     return filters | ||||
|  | ||||
|  | ||||
| class WeasyprintLabelMixin(WeasyTemplateResponseMixin): | ||||
|     """ | ||||
|     Class for rendering a label to a PDF | ||||
| @@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate): | ||||
|  | ||||
|     filters = models.CharField( | ||||
|         blank=True, max_length=250, | ||||
|         help_text=_('Query filters (comma-separated list of key=value pairs'), | ||||
|         help_text=_('Query filters (comma-separated list of key=value pairs),'), | ||||
|         verbose_name=_('Filters'), | ||||
|         validators=[ | ||||
|             validate_stock_item_filters] | ||||
|             validate_stock_item_filters | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     def matches_stock_item(self, item): | ||||
| @@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate): | ||||
|             'location': location, | ||||
|             'qr_data': location.format_barcode(brief=True), | ||||
|         } | ||||
|  | ||||
|  | ||||
| class PartLabel(LabelTemplate): | ||||
|     """ | ||||
|     Template for printing Part labels | ||||
|     """ | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_api_url(): | ||||
|         return reverse('api-part-label-list') | ||||
|  | ||||
|     SUBDIR = 'part' | ||||
|  | ||||
|     filters = models.CharField( | ||||
|         blank=True, max_length=250, | ||||
|         help_text=_('Part query filters (comma-separated value of key=value pairs)'), | ||||
|         verbose_name=_('Filters'), | ||||
|         validators=[ | ||||
|             validate_part_filters | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     def matches_part(self, part): | ||||
|         """ | ||||
|         Test if this label template matches a given Part object | ||||
|         """ | ||||
|  | ||||
|         try: | ||||
|             filters = validateFilterString(self.filters) | ||||
|             parts = part.models.Part.objects.filter(**filters) | ||||
|         except (ValidationError, FieldError): | ||||
|             return False | ||||
|  | ||||
|         parts = parts.filter(pk=part.pk) | ||||
|  | ||||
|         return parts.exists() | ||||
|  | ||||
|     def get_context_data(self, request): | ||||
|         """ | ||||
|         Generate context data for each provided Part object | ||||
|         """ | ||||
|  | ||||
|         part = self.object_to_print | ||||
|  | ||||
|         return { | ||||
|             'part': part, | ||||
|             'category': part.category, | ||||
|             'name': part.name, | ||||
|             'description': part.description, | ||||
|             'IPN': part.IPN, | ||||
|             'revision': part.revision, | ||||
|             'qr_data': part.format_barcode(brief=True), | ||||
|             'qr_url': part.format_barcode(url=True, request=request), | ||||
|         } | ||||
|   | ||||
| @@ -4,7 +4,7 @@ from __future__ import unicode_literals | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField | ||||
|  | ||||
| from .models import StockItemLabel, StockLocationLabel | ||||
| from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||
|  | ||||
|  | ||||
| class StockItemLabelSerializer(InvenTreeModelSerializer): | ||||
| @@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer): | ||||
|             'filters', | ||||
|             'enabled', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartLabelSerializer(InvenTreeModelSerializer): | ||||
|     """ | ||||
|     Serializes a PartLabel object | ||||
|     """ | ||||
|  | ||||
|     label = InvenTreeAttachmentSerializerField(required=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = PartLabel | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'name', | ||||
|             'description', | ||||
|             'label', | ||||
|             'filters', | ||||
|             'enabled', | ||||
|         ] | ||||
|   | ||||
							
								
								
									
										33
									
								
								InvenTree/label/templates/label/part/part_label.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								InvenTree/label/templates/label/part/part_label.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| {% extends "label/label_base.html" %} | ||||
|  | ||||
| {% load barcode %} | ||||
|  | ||||
| {% block style %} | ||||
|  | ||||
| .qr { | ||||
|     position: fixed; | ||||
|     left: 0mm; | ||||
|     top: 0mm; | ||||
|     height: {{ height }}mm; | ||||
|     width: {{ height }}mm; | ||||
| } | ||||
|  | ||||
| .part { | ||||
|     font-family: Arial, Helvetica, sans-serif; | ||||
|     display: inline; | ||||
|     position: absolute; | ||||
|     left: {{ height }}mm; | ||||
|     top: 2mm; | ||||
| } | ||||
|  | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <img class='qr' src='{% qrcode qr_data %}'> | ||||
|  | ||||
| <div class='part'> | ||||
|     {{ part.full_name }} | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -43,8 +43,10 @@ def get_next_po_number(): | ||||
|  | ||||
|     attempts = set([order.reference]) | ||||
|  | ||||
|     reference = order.reference | ||||
|  | ||||
|     while 1: | ||||
|         reference = increment(order.reference) | ||||
|         reference = increment(reference) | ||||
|  | ||||
|         if reference in attempts: | ||||
|             # Escape infinite recursion | ||||
| @@ -70,8 +72,10 @@ def get_next_so_number(): | ||||
|  | ||||
|     attempts = set([order.reference]) | ||||
|  | ||||
|     reference = order.reference | ||||
|  | ||||
|     while 1: | ||||
|         reference = increment(order.reference) | ||||
|         reference = increment(reference) | ||||
|  | ||||
|         if reference in attempts: | ||||
|             # Escape infinite recursion | ||||
|   | ||||
| @@ -425,18 +425,18 @@ class PartFilter(rest_filters.FilterSet): | ||||
|         else: | ||||
|             queryset = queryset.filter(IPN='') | ||||
|  | ||||
|     # Regex filter for name | ||||
|     name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') | ||||
|  | ||||
|     # Exact match for IPN | ||||
|     ipn = rest_filters.CharFilter( | ||||
|     IPN = rest_filters.CharFilter( | ||||
|         label='Filter by exact IPN (internal part number)', | ||||
|         field_name='IPN', | ||||
|         lookup_expr="iexact" | ||||
|     ) | ||||
|  | ||||
|     # Regex match for IPN | ||||
|     ipn_regex = rest_filters.CharFilter( | ||||
|         label='Filter by regex on IPN (internal part number) field', | ||||
|         field_name='IPN', lookup_expr='iregex' | ||||
|     ) | ||||
|     IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex') | ||||
|  | ||||
|     # low_stock filter | ||||
|     low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock') | ||||
| @@ -1115,10 +1115,10 @@ part_api_urls = [ | ||||
|  | ||||
|     # Base URL for PartParameter API endpoints | ||||
|     url(r'^parameter/', include([ | ||||
|         url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'), | ||||
|         url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), | ||||
|  | ||||
|         url(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-param-detail'), | ||||
|         url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'), | ||||
|         url(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), | ||||
|         url(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), | ||||
|     ])), | ||||
|  | ||||
|     url(r'^thumbs/', include([ | ||||
|   | ||||
| @@ -2164,7 +2164,7 @@ class PartParameterTemplate(models.Model): | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_api_url(): | ||||
|         return reverse('api-part-param-template-list') | ||||
|         return reverse('api-part-parameter-template-list') | ||||
|  | ||||
|     def __str__(self): | ||||
|         s = str(self.name) | ||||
| @@ -2205,7 +2205,7 @@ class PartParameter(models.Model): | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_api_url(): | ||||
|         return reverse('api-part-param-list') | ||||
|         return reverse('api-part-parameter-list') | ||||
|  | ||||
|     def __str__(self): | ||||
|         # String representation of a PartParameter (used in the admin interface) | ||||
|   | ||||
| @@ -508,19 +508,6 @@ class BomItemSerializer(InvenTreeModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartParameterSerializer(InvenTreeModelSerializer): | ||||
|     """ JSON serializers for the PartParameter model """ | ||||
|  | ||||
|     class Meta: | ||||
|         model = PartParameter | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'part', | ||||
|             'template', | ||||
|             'data' | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartParameterTemplateSerializer(InvenTreeModelSerializer): | ||||
|     """ JSON serializer for the PartParameterTemplate model """ | ||||
|  | ||||
| @@ -533,6 +520,22 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartParameterSerializer(InvenTreeModelSerializer): | ||||
|     """ JSON serializers for the PartParameter model """ | ||||
|  | ||||
|     template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = PartParameter | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'part', | ||||
|             'template', | ||||
|             'template_detail', | ||||
|             'data' | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class CategoryParameterTemplateSerializer(InvenTreeModelSerializer): | ||||
|     """ Serializer for PartCategoryParameterTemplate """ | ||||
|  | ||||
|   | ||||
| @@ -34,9 +34,7 @@ | ||||
| {{ block.super }} | ||||
|     $("#start-build").click(function() { | ||||
|         newBuildOrder({ | ||||
|             data: { | ||||
|                 part: {{ part.id }}, | ||||
|             } | ||||
|             part: {{ part.pk }}, | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| {% extends "modal_delete_form.html" %} | ||||
|  | ||||
| {% block pre_form_content %} | ||||
| Are you sure you want to remove this parameter? | ||||
| {% endblock %} | ||||
| @@ -21,54 +21,43 @@ | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <table id='param-table' class='table table-condensed table-striped' data-toolbar='#button-toolbar'> | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th data-field='name' data-serachable='true'>{% trans "Name" %}</th> | ||||
|             <th data-field='value' data-searchable='true'>{% trans "Value" %}</th> | ||||
|             <th data-field='units' data-searchable='true'>{% trans "Units" %}</th> | ||||
|         </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         {% for param in part.get_parameters %} | ||||
|         <tr> | ||||
|             <td>{{ param.template.name }}</td> | ||||
|             <td>{{ param.data }}</td> | ||||
|             <td> | ||||
|                 {{ param.template.units }} | ||||
|                 <div class='btn-group' style='float: right;'> | ||||
|                     {% if roles.part.change %} | ||||
|                     <button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button> | ||||
|                     {% endif %} | ||||
|                     {% if roles.part.change %} | ||||
|                     <button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button> | ||||
|                     {% endif %} | ||||
|                 </div> | ||||
|             </td> | ||||
|         </tr> | ||||
|         {% endfor %} | ||||
|     </tbody> | ||||
| </table> | ||||
|  | ||||
| <table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#button-toolbar"></table> | ||||
|  | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js_ready %} | ||||
| {{ block.super }} | ||||
|  | ||||
|     loadPartParameterTable( | ||||
|         '#parameter-table',  | ||||
|         '{% url "api-part-parameter-list" %}', | ||||
|         { | ||||
|             params: { | ||||
|                 part: {{ part.pk }}, | ||||
|             } | ||||
|         } | ||||
|     ); | ||||
|  | ||||
|     $('#param-table').inventreeTable({ | ||||
|     }); | ||||
|  | ||||
|     {% if roles.part.add %} | ||||
|     $('#param-create').click(function() { | ||||
|         launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", { | ||||
|             reload: true, | ||||
|             secondary: [{ | ||||
|                 field: 'template', | ||||
|                 label: '{% trans "New Template" %}', | ||||
|                 title: '{% trans "Create New Parameter Template" %}', | ||||
|                 url: "{% url 'part-param-template-create' %}" | ||||
|             }], | ||||
|  | ||||
|         constructForm('{% url "api-part-parameter-list" %}', { | ||||
|             method: 'POST', | ||||
|             fields: { | ||||
|                 part: { | ||||
|                     value: {{ part.pk }}, | ||||
|                     hidden: true, | ||||
|                 }, | ||||
|                 template: {}, | ||||
|                 data: {}, | ||||
|             }, | ||||
|             title: '{% trans "Add Parameter" %}', | ||||
|             onSuccess: function() { | ||||
|                 $('#parameter-table').bootstrapTable('refresh'); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|     {% endif %} | ||||
|   | ||||
| @@ -268,6 +268,10 @@ | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $('#print-label').click(function() { | ||||
|         printPartLabels([{{ part.pk }}]); | ||||
|     }); | ||||
|  | ||||
|     $("#part-count").click(function() { | ||||
|         launchModalForm("/stock/adjust/", { | ||||
|             data: { | ||||
|   | ||||
| @@ -805,7 +805,7 @@ class PartParameterTest(InvenTreeAPITestCase): | ||||
|         Test for listing part parameters | ||||
|         """ | ||||
|  | ||||
|         url = reverse('api-part-param-list') | ||||
|         url = reverse('api-part-parameter-list') | ||||
|  | ||||
|         response = self.client.get(url, format='json') | ||||
|  | ||||
| @@ -838,7 +838,7 @@ class PartParameterTest(InvenTreeAPITestCase): | ||||
|         Test that we can create a param via the API | ||||
|         """ | ||||
|  | ||||
|         url = reverse('api-part-param-list') | ||||
|         url = reverse('api-part-parameter-list') | ||||
|  | ||||
|         response = self.client.post( | ||||
|             url, | ||||
| @@ -860,7 +860,7 @@ class PartParameterTest(InvenTreeAPITestCase): | ||||
|         Tests for the PartParameter detail endpoint | ||||
|         """ | ||||
|  | ||||
|         url = reverse('api-part-param-detail', kwargs={'pk': 5}) | ||||
|         url = reverse('api-part-parameter-detail', kwargs={'pk': 5}) | ||||
|  | ||||
|         response = self.client.get(url) | ||||
|  | ||||
|   | ||||
| @@ -33,10 +33,6 @@ part_parameter_urls = [ | ||||
|     url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), | ||||
|     url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'), | ||||
|     url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'), | ||||
|  | ||||
|     url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'), | ||||
|     url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'), | ||||
|     url(r'^(?P<pk>\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'), | ||||
| ] | ||||
|  | ||||
| part_detail_urls = [ | ||||
|   | ||||
| @@ -32,7 +32,7 @@ from rapidfuzz import fuzz | ||||
| from decimal import Decimal, InvalidOperation | ||||
|  | ||||
| from .models import PartCategory, Part, PartRelated | ||||
| from .models import PartParameterTemplate, PartParameter | ||||
| from .models import PartParameterTemplate | ||||
| from .models import PartCategoryParameterTemplate | ||||
| from .models import BomItem | ||||
| from .models import match_part_names | ||||
| @@ -2257,78 +2257,6 @@ class PartParameterTemplateDelete(AjaxDeleteView): | ||||
|     ajax_form_title = _("Delete Part Parameter Template") | ||||
|  | ||||
|  | ||||
| class PartParameterCreate(AjaxCreateView): | ||||
|     """ View for creating a new PartParameter """ | ||||
|  | ||||
|     model = PartParameter | ||||
|     form_class = part_forms.EditPartParameterForm | ||||
|     ajax_form_title = _('Create Part Parameter') | ||||
|  | ||||
|     def get_initial(self): | ||||
|  | ||||
|         initials = {} | ||||
|  | ||||
|         part_id = self.request.GET.get('part', None) | ||||
|  | ||||
|         if part_id: | ||||
|             try: | ||||
|                 initials['part'] = Part.objects.get(pk=part_id) | ||||
|             except (Part.DoesNotExist, ValueError): | ||||
|                 pass | ||||
|  | ||||
|         return initials | ||||
|  | ||||
|     def get_form(self): | ||||
|         """ Return the form object. | ||||
|  | ||||
|         - Hide the 'Part' field (specified in URL) | ||||
|         - Limit the 'Template' options (to avoid duplicates) | ||||
|         """ | ||||
|  | ||||
|         form = super().get_form() | ||||
|  | ||||
|         part_id = self.request.GET.get('part', None) | ||||
|  | ||||
|         if part_id: | ||||
|             try: | ||||
|                 part = Part.objects.get(pk=part_id) | ||||
|  | ||||
|                 form.fields['part'].widget = HiddenInput() | ||||
|  | ||||
|                 query = form.fields['template'].queryset | ||||
|  | ||||
|                 query = query.exclude(id__in=[param.template.id for param in part.parameters.all()]) | ||||
|  | ||||
|                 form.fields['template'].queryset = query | ||||
|  | ||||
|             except (Part.DoesNotExist, ValueError): | ||||
|                 pass | ||||
|  | ||||
|         return form | ||||
|  | ||||
|  | ||||
| class PartParameterEdit(AjaxUpdateView): | ||||
|     """ View for editing a PartParameter """ | ||||
|  | ||||
|     model = PartParameter | ||||
|     form_class = part_forms.EditPartParameterForm | ||||
|     ajax_form_title = _('Edit Part Parameter') | ||||
|  | ||||
|     def get_form(self): | ||||
|  | ||||
|         form = super().get_form() | ||||
|  | ||||
|         return form | ||||
|  | ||||
|  | ||||
| class PartParameterDelete(AjaxDeleteView): | ||||
|     """ View for deleting a PartParameter """ | ||||
|  | ||||
|     model = PartParameter | ||||
|     ajax_template_name = 'part/param_delete.html' | ||||
|     ajax_form_title = _('Delete Part Parameter') | ||||
|  | ||||
|  | ||||
| class CategoryDetail(InvenTreeRoleMixin, DetailView): | ||||
|     """ Detail view for PartCategory """ | ||||
|  | ||||
|   | ||||
| @@ -100,7 +100,7 @@ class StockTest(TestCase): | ||||
|         # And there should be *no* items being build | ||||
|         self.assertEqual(part.quantity_being_built, 0) | ||||
|  | ||||
|         build = Build.objects.create(part=part, title='A test build', quantity=1) | ||||
|         build = Build.objects.create(reference='12345', part=part, title='A test build', quantity=1) | ||||
|  | ||||
|         # Add some stock items which are "building" | ||||
|         for i in range(10): | ||||
|   | ||||
| @@ -75,7 +75,7 @@ | ||||
| {{ block.super }} | ||||
|  | ||||
|     $("#param-table").inventreeTable({ | ||||
|         url: "{% url 'api-part-param-template-list' %}", | ||||
|         url: "{% url 'api-part-parameter-template-list' %}", | ||||
|         queryParams: { | ||||
|             ordering: 'name', | ||||
|         }, | ||||
|   | ||||
| @@ -1,34 +1,72 @@ | ||||
| {% load i18n %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
|  | ||||
| function buildFormFields() { | ||||
|     return { | ||||
|         reference: { | ||||
|             prefix: "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}", | ||||
|         }, | ||||
|         title: {}, | ||||
|         part: {}, | ||||
|         quantity: {}, | ||||
|         parent: { | ||||
|             filters: { | ||||
|                 part_detail: true, | ||||
|             } | ||||
|         }, | ||||
|         batch: {}, | ||||
|         target_date: {}, | ||||
|         take_from: {}, | ||||
|         destination: {}, | ||||
|         link: { | ||||
|             icon: 'fa-link', | ||||
|         }, | ||||
|         issued_by: { | ||||
|             icon: 'fa-user', | ||||
|         }, | ||||
|         responsible: { | ||||
|             icon: 'fa-users', | ||||
|         }, | ||||
|     }; | ||||
| } | ||||
|  | ||||
|  | ||||
| function editBuildOrder(pk, options={}) { | ||||
|  | ||||
|     var fields = buildFormFields(); | ||||
|  | ||||
|     constructForm(`/api/build/${pk}/`, { | ||||
|         fields: fields, | ||||
|         reload: true, | ||||
|         title: '{% trans "Edit Build Order" %}', | ||||
|     }); | ||||
| } | ||||
|  | ||||
| function newBuildOrder(options={}) { | ||||
|     /* Launch modal form to create a new BuildOrder. | ||||
|      */ | ||||
|  | ||||
|     launchModalForm( | ||||
|         "{% url 'build-create' %}", | ||||
|         { | ||||
|             follow: true, | ||||
|             data: options.data || {}, | ||||
|             callback: [ | ||||
|                 { | ||||
|                     field: 'part', | ||||
|                     action: function(value) { | ||||
|                         inventreeGet( | ||||
|                             `/api/part/${value}/`, {}, | ||||
|                             { | ||||
|                                 success: function(response) { | ||||
|     var fields = buildFormFields(); | ||||
|  | ||||
|                                     //enableField('serial_numbers', response.trackable); | ||||
|                                     //clearField('serial_numbers'); | ||||
|                                 } | ||||
|                             } | ||||
|                         ); | ||||
|                     }, | ||||
|                 } | ||||
|             ], | ||||
|         } | ||||
|     ) | ||||
|     if (options.part) { | ||||
|         fields.part.value = options.part; | ||||
|     } | ||||
|  | ||||
|     if (options.quantity) { | ||||
|         fields.quantity.value = options.quantity; | ||||
|     } | ||||
|  | ||||
|     if (options.parent) { | ||||
|         fields.parent.value = options.parent; | ||||
|     } | ||||
|  | ||||
|     constructForm(`/api/build/`, { | ||||
|         fields: fields, | ||||
|         follow: true, | ||||
|         method: 'POST', | ||||
|         title: '{% trans "Create Build Order" %}' | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -384,14 +422,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { | ||||
|             var idx = $(this).closest('tr').attr('data-index'); | ||||
|             var row = $(table).bootstrapTable('getData')[idx]; | ||||
|  | ||||
|             // Launch form to create a new build order | ||||
|             launchModalForm('{% url "build-create" %}', { | ||||
|                 follow: true, | ||||
|                 data: { | ||||
|                     part: pk, | ||||
|                     parent: buildId, | ||||
|                     quantity: requiredQuantity(row) - sumAllocations(row), | ||||
|                 } | ||||
|             newBuildOrder({ | ||||
|                 part: pk, | ||||
|                 parent: buildId, | ||||
|                 quantity: requiredQuantity(row) - sumAllocations(row), | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
| @@ -1092,13 +1126,9 @@ function loadBuildPartsTable(table, options={}) { | ||||
|             var idx = $(this).closest('tr').attr('data-index'); | ||||
|             var row = $(table).bootstrapTable('getData')[idx]; | ||||
|  | ||||
|             // Launch form to create a new build order | ||||
|             launchModalForm('{% url "build-create" %}', { | ||||
|                 follow: true, | ||||
|                 data: { | ||||
|                     part: pk, | ||||
|                     parent: options.build, | ||||
|                 } | ||||
|             newBuildOrder({ | ||||
|                 part: pk, | ||||
|                 parent: options.build, | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|   | ||||
| @@ -511,6 +511,10 @@ function insertConfirmButton(options) { | ||||
|  */ | ||||
| function submitFormData(fields, options) { | ||||
|  | ||||
|     // Immediately disable the "submit" button, | ||||
|     // to prevent the form being submitted multiple times! | ||||
|     $(options.modal).find('#modal-form-submit').prop('disabled', true); | ||||
|  | ||||
|     // Form data to be uploaded to the server | ||||
|     // Only used if file / image upload is required | ||||
|     var form_data = new FormData(); | ||||
| @@ -728,11 +732,31 @@ function handleFormSuccess(response, options) { | ||||
|  | ||||
|     // Close the modal | ||||
|     if (!options.preventClose) { | ||||
|         // TODO: Actually just *delete* the modal, | ||||
|         // rather than hiding it!! | ||||
|         // Note: The modal will be deleted automatically after closing | ||||
|         $(options.modal).modal('hide'); | ||||
|     } | ||||
|  | ||||
|     // Display any required messages | ||||
|     // Should we show alerts immediately or cache them? | ||||
|     var cache = (options.follow && response.url) || options.redirect || options.reload; | ||||
|  | ||||
|     // Display any messages | ||||
|     if (response && response.success) { | ||||
|         showAlertOrCache("alert-success", response.success, cache); | ||||
|     } | ||||
|      | ||||
|     if (response && response.info) { | ||||
|         showAlertOrCache("alert-info", response.info, cache); | ||||
|     } | ||||
|  | ||||
|     if (response && response.warning) { | ||||
|         showAlertOrCache("alert-warning", response.warning, cache); | ||||
|     } | ||||
|  | ||||
|     if (response && response.danger) { | ||||
|         showAlertOrCache("alert-danger", response.danger, cache); | ||||
|     } | ||||
|  | ||||
|     if (options.onSuccess) { | ||||
|         // Callback function | ||||
|         options.onSuccess(response, options); | ||||
| @@ -778,6 +802,9 @@ function clearFormErrors(options) { | ||||
|  */ | ||||
| function handleFormErrors(errors, fields, options) { | ||||
|  | ||||
|     // Reset the status of the "submit" button | ||||
|     $(options.modal).find('#modal-form-submit').prop('disabled', false); | ||||
|  | ||||
|     // Remove any existing error messages from the form | ||||
|     clearFormErrors(options); | ||||
|  | ||||
| @@ -1201,11 +1228,21 @@ function renderModelData(name, model, data, parameters, options) { | ||||
|         case 'partcategory': | ||||
|             renderer = renderPartCategory; | ||||
|             break; | ||||
|         case 'partparametertemplate': | ||||
|             renderer = renderPartParameterTemplate; | ||||
|             break; | ||||
|         case 'supplierpart': | ||||
|             renderer = renderSupplierPart; | ||||
|             break; | ||||
|         case 'build': | ||||
|             renderer = renderBuild; | ||||
|             break; | ||||
|         case 'owner': | ||||
|             renderer = renderOwner; | ||||
|             break; | ||||
|         case 'user': | ||||
|             renderer = renderUser; | ||||
|             break; | ||||
|         default: | ||||
|             break; | ||||
|     } | ||||
|   | ||||
| @@ -105,6 +105,61 @@ function printStockLocationLabels(locations, options={}) { | ||||
| } | ||||
|  | ||||
|  | ||||
| function printPartLabels(parts, options={}) { | ||||
|     /** | ||||
|      * Print labels for the provided parts | ||||
|      */ | ||||
|  | ||||
|     if (parts.length == 0) { | ||||
|         showAlertDialog( | ||||
|             '{% trans "Select Parts" %}', | ||||
|             '{% trans "Part(s) must be selected before printing labels" %}', | ||||
|         ); | ||||
|  | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     // Request available labels from the server | ||||
|     inventreeGet( | ||||
|         '{% url "api-part-label-list" %}', | ||||
|         { | ||||
|             enabled: true, | ||||
|             parts: parts, | ||||
|         }, | ||||
|         { | ||||
|             success: function(response) { | ||||
|  | ||||
|                 if (response.length == 0) { | ||||
|                     showAlertDialog( | ||||
|                         '{% trans "No Labels Found" %}', | ||||
|                         '{% trans "No labels found which match the selected part(s)" %}', | ||||
|                     ); | ||||
|  | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 // Select label to print | ||||
|                 selectLabel( | ||||
|                     response, | ||||
|                     parts, | ||||
|                     { | ||||
|                         success: function(pk) { | ||||
|                             var url = `/api/label/part/${pk}/print/?`; | ||||
|  | ||||
|                             parts.forEach(function(part) { | ||||
|                                 url += `parts[]=${part}&`; | ||||
|                             }); | ||||
|  | ||||
|                             window.location.href = url; | ||||
|                         } | ||||
|                     } | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|     ); | ||||
| } | ||||
|  | ||||
|  | ||||
| function selectLabel(labels, items, options={}) { | ||||
|     /** | ||||
|      * Present the user with the available labels, | ||||
|   | ||||
| @@ -83,8 +83,6 @@ function createNewModal(options={}) { | ||||
|  | ||||
|     // Capture "enter" key input | ||||
|     $(modal_name).on('keydown', 'input', function(event) { | ||||
|  | ||||
|          | ||||
|         if (event.keyCode == 13) { | ||||
|             event.preventDefault(); | ||||
|             // Simulate a click on the 'Submit' button | ||||
|   | ||||
| @@ -70,6 +70,27 @@ function renderStockLocation(name, data, parameters, options) { | ||||
| } | ||||
|  | ||||
|  | ||||
| function renderBuild(name, data, parameters, options) { | ||||
|      | ||||
|     var image = ''; | ||||
|  | ||||
|     if (data.part_detail && data.part_detail.thumbnail) { | ||||
|         image = data.part_detail.thumbnail; | ||||
|     } else { | ||||
|         image = `/static/img/blank_image.png`; | ||||
|     } | ||||
|  | ||||
|     var html = `<img src='${image}' class='select2-thumbnail'>`; | ||||
|  | ||||
|     html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`; | ||||
|     html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`; | ||||
|  | ||||
|     html += `<p><i>${data.title}</i></p>`; | ||||
|  | ||||
|     return html; | ||||
| } | ||||
|  | ||||
|  | ||||
| // Renderer for "Part" model | ||||
| function renderPart(name, data, parameters, options) { | ||||
|  | ||||
| @@ -92,6 +113,18 @@ function renderPart(name, data, parameters, options) { | ||||
|     return html; | ||||
| } | ||||
|  | ||||
| // Renderer for "User" model | ||||
| function renderUser(name, data, parameters, options) { | ||||
|  | ||||
|     var html = `<span>${data.username}</span>`; | ||||
|  | ||||
|     if (data.first_name && data.last_name) { | ||||
|         html += ` - <i>${data.first_name} ${data.last_name}</i>`; | ||||
|     } | ||||
|  | ||||
|     return html; | ||||
| } | ||||
|  | ||||
|  | ||||
| // Renderer for "Owner" model | ||||
| function renderOwner(name, data, parameters, options) { | ||||
| @@ -133,6 +166,14 @@ function renderPartCategory(name, data, parameters, options) { | ||||
| } | ||||
|  | ||||
|  | ||||
| function renderPartParameterTemplate(name, data, parameters, options) { | ||||
|  | ||||
|     var html = `<span>${data.name} - [${data.units}]</span>`; | ||||
|  | ||||
|     return html; | ||||
| } | ||||
|  | ||||
|  | ||||
| // Rendered for "SupplierPart" model | ||||
| function renderSupplierPart(name, data, parameters, options) { | ||||
|  | ||||
|   | ||||
| @@ -220,6 +220,107 @@ function loadSimplePartTable(table, url, options={}) { | ||||
| } | ||||
|  | ||||
|  | ||||
| function loadPartParameterTable(table, url, options) { | ||||
|  | ||||
|     var params = options.params || {}; | ||||
|  | ||||
|     // Load filters | ||||
|     var filters = loadTableFilters("part-parameters"); | ||||
|  | ||||
|     for (var key in params) { | ||||
|         filters[key] = params[key]; | ||||
|     } | ||||
|  | ||||
|     // setupFilterLsit("#part-parameters", $(table)); | ||||
|  | ||||
|     $(table).inventreeTable({ | ||||
|         url: url, | ||||
|         original: params, | ||||
|         queryParams: filters, | ||||
|         name: 'partparameters', | ||||
|         groupBy: false, | ||||
|         formatNoMatches: function() { return '{% trans "No parameters found" %}'; }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 checkbox: true, | ||||
|                 switchable: false, | ||||
|                 visible: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'name', | ||||
|                 title: '{% trans "Name" %}', | ||||
|                 switchable: false, | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     return row.template_detail.name; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'data', | ||||
|                 title: '{% trans "Value" %}', | ||||
|                 switchable: false, | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'units', | ||||
|                 title: '{% trans "Units" %}', | ||||
|                 switchable: true, | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     return row.template_detail.units; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'actions', | ||||
|                 title: '', | ||||
|                 switchable: false, | ||||
|                 sortable: false, | ||||
|                 formatter: function(value, row) { | ||||
|                     var pk = row.pk; | ||||
|  | ||||
|                     var html = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|                     html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}'); | ||||
|                     html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}'); | ||||
|  | ||||
|                     html += `</div>`; | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             } | ||||
|         ], | ||||
|         onPostBody: function() { | ||||
|             // Setup button callbacks | ||||
|             $(table).find('.button-parameter-edit').click(function() { | ||||
|                 var pk = $(this).attr('pk'); | ||||
|  | ||||
|                 constructForm(`/api/part/parameter/${pk}/`, { | ||||
|                     fields: { | ||||
|                         data: {}, | ||||
|                     }, | ||||
|                     title: '{% trans "Edit Parameter" %}', | ||||
|                     onSuccess: function() { | ||||
|                         $(table).bootstrapTable('refresh'); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             $(table).find('.button-parameter-delete').click(function() { | ||||
|                 var pk = $(this).attr('pk'); | ||||
|  | ||||
|                 constructForm(`/api/part/parameter/${pk}/`, { | ||||
|                     method: 'DELETE', | ||||
|                     title: '{% trans "Delete Parameter" %}', | ||||
|                     onSuccess: function() { | ||||
|                         $(table).bootstrapTable('refresh'); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| function loadParametricPartTable(table, options={}) { | ||||
|     /* Load parametric table for part parameters | ||||
|      *  | ||||
|   | ||||
| @@ -87,6 +87,7 @@ class RuleSet(models.Model): | ||||
|             'company_supplierpart', | ||||
|             'company_manufacturerpart', | ||||
|             'company_manufacturerpartparameter', | ||||
|             'label_partlabel', | ||||
|         ], | ||||
|         'stock_location': [ | ||||
|             'stock_stocklocation', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user