mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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: |         if 'default' not in field_info and not field.default == empty: | ||||||
|             field_info['default'] = field.get_default() |             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 |         # Introspect writable related fields | ||||||
|         if field_info['type'] == 'field' and not field_info['read_only']: |         if field_info['type'] == 'field' and not field_info['read_only']: | ||||||
|              |              | ||||||
| @@ -166,7 +171,12 @@ class InvenTreeMetadata(SimpleMetadata): | |||||||
|             if model: |             if model: | ||||||
|                 # Mark this field as "related", and point to the URL where we can get the data! |                 # Mark this field as "related", and point to the URL where we can get the data! | ||||||
|                 field_info['type'] = 'related field' |                 field_info['type'] = 'related field' | ||||||
|                 field_info['api_url'] = model.get_api_url() |  | ||||||
|                 field_info['model'] = model._meta.model_name |                 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 |         return field_info | ||||||
|   | |||||||
| @@ -5,11 +5,13 @@ JSON API for the Build app | |||||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||||
| from __future__ import unicode_literals | 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 filters | ||||||
| from rest_framework import generics | 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.api import AttachmentMixin | ||||||
| from InvenTree.helpers import str2bool, isNull | from InvenTree.helpers import str2bool, isNull | ||||||
| @@ -19,6 +21,36 @@ from .models import Build, BuildItem, BuildOrderAttachment | |||||||
| from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer | 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): | class BuildList(generics.ListCreateAPIView): | ||||||
|     """ API endpoint for accessing a list of Build objects. |     """ API endpoint for accessing a list of Build objects. | ||||||
|  |  | ||||||
| @@ -28,6 +60,7 @@ class BuildList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|     queryset = Build.objects.all() |     queryset = Build.objects.all() | ||||||
|     serializer_class = BuildSerializer |     serializer_class = BuildSerializer | ||||||
|  |     filterset_class = BuildFilter | ||||||
|  |  | ||||||
|     filter_backends = [ |     filter_backends = [ | ||||||
|         DjangoFilterBackend, |         DjangoFilterBackend, | ||||||
| @@ -97,34 +130,6 @@ class BuildList(generics.ListCreateAPIView): | |||||||
|             except (ValueError, Build.DoesNotExist): |             except (ValueError, Build.DoesNotExist): | ||||||
|                 pass |                 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? |         # Filter by associated part? | ||||||
|         part = params.get('part', None) |         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 markdownx.models import MarkdownxField | ||||||
|  |  | ||||||
| from mptt.models import MPTTModel, TreeForeignKey | from mptt.models import MPTTModel, TreeForeignKey | ||||||
|  | from mptt.exceptions import InvalidMove | ||||||
|  |  | ||||||
| from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode | from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode | ||||||
| from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode | from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode | ||||||
| @@ -37,6 +38,35 @@ from part import models as PartModels | |||||||
| from users import models as UserModels | 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): | class Build(MPTTModel): | ||||||
|     """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. |     """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. | ||||||
|  |  | ||||||
| @@ -60,11 +90,20 @@ class Build(MPTTModel): | |||||||
|         responsible: User (or group) responsible for completing the build |         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 |     @staticmethod | ||||||
|     def get_api_url(): |     def get_api_url(): | ||||||
|         return reverse('api-build-list') |         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: |     class Meta: | ||||||
|         verbose_name = _("Build Order") |         verbose_name = _("Build Order") | ||||||
| @@ -130,6 +169,7 @@ class Build(MPTTModel): | |||||||
|         blank=False, |         blank=False, | ||||||
|         help_text=_('Build Order Reference'), |         help_text=_('Build Order Reference'), | ||||||
|         verbose_name=_('Reference'), |         verbose_name=_('Reference'), | ||||||
|  |         default=get_next_build_number, | ||||||
|         validators=[ |         validators=[ | ||||||
|             validate_build_order_reference |             validate_build_order_reference | ||||||
|         ] |         ] | ||||||
|   | |||||||
| @@ -62,7 +62,7 @@ class BuildSerializer(InvenTreeModelSerializer): | |||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         part_detail = kwargs.pop('part_detail', False) |         part_detail = kwargs.pop('part_detail', True) | ||||||
|  |  | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
| @@ -75,9 +75,12 @@ class BuildSerializer(InvenTreeModelSerializer): | |||||||
|             'pk', |             'pk', | ||||||
|             'url', |             'url', | ||||||
|             'title', |             'title', | ||||||
|  |             'batch', | ||||||
|             'creation_date', |             'creation_date', | ||||||
|             'completed', |             'completed', | ||||||
|             'completion_date', |             'completion_date', | ||||||
|  |             'destination', | ||||||
|  |             'parent', | ||||||
|             'part', |             'part', | ||||||
|             'part_detail', |             'part_detail', | ||||||
|             'overdue', |             'overdue', | ||||||
| @@ -87,6 +90,7 @@ class BuildSerializer(InvenTreeModelSerializer): | |||||||
|             'status', |             'status', | ||||||
|             'status_text', |             'status_text', | ||||||
|             'target_date', |             'target_date', | ||||||
|  |             'take_from', | ||||||
|             'notes', |             'notes', | ||||||
|             'link', |             'link', | ||||||
|             'issued_by', |             'issued_by', | ||||||
|   | |||||||
| @@ -196,10 +196,7 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     $("#build-edit").click(function () { |     $("#build-edit").click(function () { | ||||||
|         launchModalForm("{% url 'build-edit' build.id %}", |         editBuildOrder({{ build.pk }}); | ||||||
|                         { |  | ||||||
|                             reload: true |  | ||||||
|                         }); |  | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     $("#build-cancel").click(function() { |     $("#build-cancel").click(function() { | ||||||
|   | |||||||
| @@ -5,10 +5,11 @@ from django.test import TestCase | |||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
| from django.db.utils import IntegrityError | 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 stock.models import StockItem | ||||||
| from part.models import Part, BomItem | from part.models import Part, BomItem | ||||||
| from InvenTree import status_codes as status |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildTest(TestCase): | class BuildTest(TestCase): | ||||||
| @@ -80,8 +81,14 @@ class BuildTest(TestCase): | |||||||
|             quantity=2 |             quantity=2 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         ref = get_next_build_number() | ||||||
|  |  | ||||||
|  |         if ref is None: | ||||||
|  |             ref = "0001" | ||||||
|  |  | ||||||
|         # Create a "Build" object to make 10x objects |         # Create a "Build" object to make 10x objects | ||||||
|         self.build = Build.objects.create( |         self.build = Build.objects.create( | ||||||
|  |             reference=ref, | ||||||
|             title="This is a build", |             title="This is a build", | ||||||
|             part=self.assembly, |             part=self.assembly, | ||||||
|             quantity=10 |             quantity=10 | ||||||
|   | |||||||
| @@ -252,23 +252,6 @@ class TestBuildViews(TestCase): | |||||||
|  |  | ||||||
|         self.assertIn(build.title, content) |         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): |     def test_build_allocate(self): | ||||||
|         """ Test the part allocation view for a Build """ |         """ Test the part allocation view for a Build """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,6 @@ from django.conf.urls import url, include | |||||||
| from . import views | from . import views | ||||||
|  |  | ||||||
| build_detail_urls = [ | 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'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'), | ||||||
|     url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), |     url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), | ||||||
|     url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), |     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('^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'^(?P<pk>\d+)/', include(build_detail_urls)), | ||||||
|  |  | ||||||
|     url(r'.*$', views.BuildIndex.as_view(), name='build-index'), |     url(r'.*$', views.BuildIndex.as_view(), name='build-index'), | ||||||
|   | |||||||
| @@ -667,126 +667,6 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView): | |||||||
|         return context |         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): | class BuildDelete(AjaxDeleteView): | ||||||
|     """ View to delete a build """ |     """ View to delete a build """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ from __future__ import unicode_literals | |||||||
|  |  | ||||||
| from django.contrib import admin | from django.contrib import admin | ||||||
|  |  | ||||||
| from .models import StockItemLabel, StockLocationLabel | from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||||
|  |  | ||||||
|  |  | ||||||
| class LabelAdmin(admin.ModelAdmin): | class LabelAdmin(admin.ModelAdmin): | ||||||
| @@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin): | |||||||
|  |  | ||||||
| admin.site.register(StockItemLabel, LabelAdmin) | admin.site.register(StockItemLabel, LabelAdmin) | ||||||
| admin.site.register(StockLocationLabel, LabelAdmin) | admin.site.register(StockLocationLabel, LabelAdmin) | ||||||
|  | admin.site.register(PartLabel, LabelAdmin) | ||||||
|   | |||||||
| @@ -15,9 +15,10 @@ import InvenTree.helpers | |||||||
| import common.models | import common.models | ||||||
|  |  | ||||||
| from stock.models import StockItem, StockLocation | from stock.models import StockItem, StockLocation | ||||||
|  | from part.models import Part | ||||||
|  |  | ||||||
| from .models import StockItemLabel, StockLocationLabel | from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||||
| from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer | from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| class LabelListView(generics.ListAPIView): | class LabelListView(generics.ListAPIView): | ||||||
| @@ -132,6 +133,7 @@ class StockItemLabelMixin: | |||||||
|         for key in ['item', 'item[]', 'items', 'items[]']: |         for key in ['item', 'item[]', 'items', 'items[]']: | ||||||
|             if key in params: |             if key in params: | ||||||
|                 items = params.getlist(key, []) |                 items = params.getlist(key, []) | ||||||
|  |                 break | ||||||
|  |  | ||||||
|         valid_ids = [] |         valid_ids = [] | ||||||
|  |  | ||||||
| @@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, | |||||||
|         return self.print(request, locations) |         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 = [ | label_api_urls = [ | ||||||
|  |  | ||||||
|     # Stock item labels |     # Stock item labels | ||||||
| @@ -401,4 +509,16 @@ label_api_urls = [ | |||||||
|         # List view |         # List view | ||||||
|         url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'), |         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(): |         if canAppAccessDatabase(): | ||||||
|             self.create_stock_item_labels() |             self.create_stock_item_labels() | ||||||
|             self.create_stock_location_labels() |             self.create_stock_location_labels() | ||||||
|  |             self.create_part_labels() | ||||||
|  |  | ||||||
|     def create_stock_item_labels(self): |     def create_stock_item_labels(self): | ||||||
|         """ |         """ | ||||||
| @@ -65,7 +66,7 @@ class LabelConfig(AppConfig): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         if not os.path.exists(dst_dir): |         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) |             os.makedirs(dst_dir, exist_ok=True) | ||||||
|  |  | ||||||
|         labels = [ |         labels = [ | ||||||
| @@ -109,24 +110,21 @@ class LabelConfig(AppConfig): | |||||||
|                 logger.info(f"Copying label template '{dst_file}'") |                 logger.info(f"Copying label template '{dst_file}'") | ||||||
|                 shutil.copyfile(src_file, dst_file) |                 shutil.copyfile(src_file, dst_file) | ||||||
|  |  | ||||||
|             try: |             # Check if a label matching the template already exists | ||||||
|                 # Check if a label matching the template already exists |             if StockItemLabel.objects.filter(label=filename).exists(): | ||||||
|                 if StockItemLabel.objects.filter(label=filename).exists(): |                 continue | ||||||
|                     continue |  | ||||||
|  |  | ||||||
|                 logger.info(f"Creating entry for StockItemLabel '{label['name']}'") |             logger.info(f"Creating entry for StockItemLabel '{label['name']}'") | ||||||
|  |  | ||||||
|                 StockItemLabel.objects.create( |             StockItemLabel.objects.create( | ||||||
|                     name=label['name'], |                 name=label['name'], | ||||||
|                     description=label['description'], |                 description=label['description'], | ||||||
|                     label=filename, |                 label=filename, | ||||||
|                     filters='', |                 filters='', | ||||||
|                     enabled=True, |                 enabled=True, | ||||||
|                     width=label['width'], |                 width=label['width'], | ||||||
|                     height=label['height'], |                 height=label['height'], | ||||||
|                 ) |             ) | ||||||
|             except: |  | ||||||
|                 pass |  | ||||||
|  |  | ||||||
|     def create_stock_location_labels(self): |     def create_stock_location_labels(self): | ||||||
|         """ |         """ | ||||||
| @@ -155,7 +153,7 @@ class LabelConfig(AppConfig): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         if not os.path.exists(dst_dir): |         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) |             os.makedirs(dst_dir, exist_ok=True) | ||||||
|  |  | ||||||
|         labels = [ |         labels = [ | ||||||
| @@ -206,21 +204,103 @@ class LabelConfig(AppConfig): | |||||||
|                 logger.info(f"Copying label template '{dst_file}'") |                 logger.info(f"Copying label template '{dst_file}'") | ||||||
|                 shutil.copyfile(src_file, dst_file) |                 shutil.copyfile(src_file, dst_file) | ||||||
|  |  | ||||||
|             try: |             # Check if a label matching the template already exists | ||||||
|                 # Check if a label matching the template already exists |             if StockLocationLabel.objects.filter(label=filename).exists(): | ||||||
|                 if StockLocationLabel.objects.filter(label=filename).exists(): |                 continue | ||||||
|                     continue |  | ||||||
|  |  | ||||||
|                 logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") |             logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") | ||||||
|  |  | ||||||
|                 StockLocationLabel.objects.create( |             StockLocationLabel.objects.create( | ||||||
|                     name=label['name'], |                 name=label['name'], | ||||||
|                     description=label['description'], |                 description=label['description'], | ||||||
|                     label=filename, |                 label=filename, | ||||||
|                     filters='', |                 filters='', | ||||||
|                     enabled=True, |                 enabled=True, | ||||||
|                     width=label['width'], |                 width=label['width'], | ||||||
|                     height=label['height'], |                 height=label['height'], | ||||||
|                 ) |             ) | ||||||
|             except: |  | ||||||
|                 pass |     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 common.models | ||||||
| import stock.models | import stock.models | ||||||
|  | import part.models | ||||||
|  |  | ||||||
|  |  | ||||||
| try: | try: | ||||||
|     from django_weasyprint import WeasyTemplateResponseMixin |     from django_weasyprint import WeasyTemplateResponseMixin | ||||||
| @@ -59,6 +61,13 @@ def validate_stock_location_filters(filters): | |||||||
|     return filters |     return filters | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def validate_part_filters(filters): | ||||||
|  |  | ||||||
|  |     filters = validateFilterString(filters, model=part.models.Part) | ||||||
|  |  | ||||||
|  |     return filters | ||||||
|  |  | ||||||
|  |  | ||||||
| class WeasyprintLabelMixin(WeasyTemplateResponseMixin): | class WeasyprintLabelMixin(WeasyTemplateResponseMixin): | ||||||
|     """ |     """ | ||||||
|     Class for rendering a label to a PDF |     Class for rendering a label to a PDF | ||||||
| @@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate): | |||||||
|  |  | ||||||
|     filters = models.CharField( |     filters = models.CharField( | ||||||
|         blank=True, max_length=250, |         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'), |         verbose_name=_('Filters'), | ||||||
|         validators=[ |         validators=[ | ||||||
|             validate_stock_item_filters] |             validate_stock_item_filters | ||||||
|  |         ] | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def matches_stock_item(self, item): |     def matches_stock_item(self, item): | ||||||
| @@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate): | |||||||
|             'location': location, |             'location': location, | ||||||
|             'qr_data': location.format_barcode(brief=True), |             '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 InvenTreeModelSerializer | ||||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField | from InvenTree.serializers import InvenTreeAttachmentSerializerField | ||||||
|  |  | ||||||
| from .models import StockItemLabel, StockLocationLabel | from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockItemLabelSerializer(InvenTreeModelSerializer): | class StockItemLabelSerializer(InvenTreeModelSerializer): | ||||||
| @@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer): | |||||||
|             'filters', |             'filters', | ||||||
|             'enabled', |             '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]) |     attempts = set([order.reference]) | ||||||
|  |  | ||||||
|  |     reference = order.reference | ||||||
|  |  | ||||||
|     while 1: |     while 1: | ||||||
|         reference = increment(order.reference) |         reference = increment(reference) | ||||||
|  |  | ||||||
|         if reference in attempts: |         if reference in attempts: | ||||||
|             # Escape infinite recursion |             # Escape infinite recursion | ||||||
| @@ -70,8 +72,10 @@ def get_next_so_number(): | |||||||
|  |  | ||||||
|     attempts = set([order.reference]) |     attempts = set([order.reference]) | ||||||
|  |  | ||||||
|  |     reference = order.reference | ||||||
|  |  | ||||||
|     while 1: |     while 1: | ||||||
|         reference = increment(order.reference) |         reference = increment(reference) | ||||||
|  |  | ||||||
|         if reference in attempts: |         if reference in attempts: | ||||||
|             # Escape infinite recursion |             # Escape infinite recursion | ||||||
|   | |||||||
| @@ -425,18 +425,18 @@ class PartFilter(rest_filters.FilterSet): | |||||||
|         else: |         else: | ||||||
|             queryset = queryset.filter(IPN='') |             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 |     # Exact match for IPN | ||||||
|     ipn = rest_filters.CharFilter( |     IPN = rest_filters.CharFilter( | ||||||
|         label='Filter by exact IPN (internal part number)', |         label='Filter by exact IPN (internal part number)', | ||||||
|         field_name='IPN', |         field_name='IPN', | ||||||
|         lookup_expr="iexact" |         lookup_expr="iexact" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Regex match for IPN |     # Regex match for IPN | ||||||
|     ipn_regex = rest_filters.CharFilter( |     IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex') | ||||||
|         label='Filter by regex on IPN (internal part number) field', |  | ||||||
|         field_name='IPN', lookup_expr='iregex' |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     # low_stock filter |     # low_stock filter | ||||||
|     low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock') |     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 |     # Base URL for PartParameter API endpoints | ||||||
|     url(r'^parameter/', include([ |     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'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), | ||||||
|         url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'), |         url(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), | ||||||
|     ])), |     ])), | ||||||
|  |  | ||||||
|     url(r'^thumbs/', include([ |     url(r'^thumbs/', include([ | ||||||
|   | |||||||
| @@ -2164,7 +2164,7 @@ class PartParameterTemplate(models.Model): | |||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def get_api_url(): |     def get_api_url(): | ||||||
|         return reverse('api-part-param-template-list') |         return reverse('api-part-parameter-template-list') | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         s = str(self.name) |         s = str(self.name) | ||||||
| @@ -2205,7 +2205,7 @@ class PartParameter(models.Model): | |||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def get_api_url(): |     def get_api_url(): | ||||||
|         return reverse('api-part-param-list') |         return reverse('api-part-parameter-list') | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         # String representation of a PartParameter (used in the admin interface) |         # 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): | class PartParameterTemplateSerializer(InvenTreeModelSerializer): | ||||||
|     """ JSON serializer for the PartParameterTemplate model """ |     """ 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): | class CategoryParameterTemplateSerializer(InvenTreeModelSerializer): | ||||||
|     """ Serializer for PartCategoryParameterTemplate """ |     """ Serializer for PartCategoryParameterTemplate """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -34,9 +34,7 @@ | |||||||
| {{ block.super }} | {{ block.super }} | ||||||
|     $("#start-build").click(function() { |     $("#start-build").click(function() { | ||||||
|         newBuildOrder({ |         newBuildOrder({ | ||||||
|             data: { |             part: {{ part.pk }}, | ||||||
|                 part: {{ part.id }}, |  | ||||||
|             } |  | ||||||
|         }); |         }); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <table id='param-table' class='table table-condensed table-striped' data-toolbar='#button-toolbar'> | <table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#button-toolbar"></table> | ||||||
|     <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> |  | ||||||
|  |  | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block js_ready %} | {% block js_ready %} | ||||||
| {{ block.super }} | {{ block.super }} | ||||||
|  |  | ||||||
|  |     loadPartParameterTable( | ||||||
|  |         '#parameter-table',  | ||||||
|  |         '{% url "api-part-parameter-list" %}', | ||||||
|  |         { | ||||||
|  |             params: { | ||||||
|  |                 part: {{ part.pk }}, | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     $('#param-table').inventreeTable({ |     $('#param-table').inventreeTable({ | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     {% if roles.part.add %} |     {% if roles.part.add %} | ||||||
|     $('#param-create').click(function() { |     $('#param-create').click(function() { | ||||||
|         launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", { |  | ||||||
|             reload: true, |         constructForm('{% url "api-part-parameter-list" %}', { | ||||||
|             secondary: [{ |             method: 'POST', | ||||||
|                 field: 'template', |             fields: { | ||||||
|                 label: '{% trans "New Template" %}', |                 part: { | ||||||
|                 title: '{% trans "Create New Parameter Template" %}', |                     value: {{ part.pk }}, | ||||||
|                 url: "{% url 'part-param-template-create' %}" |                     hidden: true, | ||||||
|             }], |                 }, | ||||||
|  |                 template: {}, | ||||||
|  |                 data: {}, | ||||||
|  |             }, | ||||||
|  |             title: '{% trans "Add Parameter" %}', | ||||||
|  |             onSuccess: function() { | ||||||
|  |                 $('#parameter-table').bootstrapTable('refresh'); | ||||||
|  |             } | ||||||
|         }); |         }); | ||||||
|     }); |     }); | ||||||
|     {% endif %} |     {% endif %} | ||||||
|   | |||||||
| @@ -268,6 +268,10 @@ | |||||||
|         ); |         ); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     $('#print-label').click(function() { | ||||||
|  |         printPartLabels([{{ part.pk }}]); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     $("#part-count").click(function() { |     $("#part-count").click(function() { | ||||||
|         launchModalForm("/stock/adjust/", { |         launchModalForm("/stock/adjust/", { | ||||||
|             data: { |             data: { | ||||||
|   | |||||||
| @@ -805,7 +805,7 @@ class PartParameterTest(InvenTreeAPITestCase): | |||||||
|         Test for listing part parameters |         Test for listing part parameters | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         url = reverse('api-part-param-list') |         url = reverse('api-part-parameter-list') | ||||||
|  |  | ||||||
|         response = self.client.get(url, format='json') |         response = self.client.get(url, format='json') | ||||||
|  |  | ||||||
| @@ -838,7 +838,7 @@ class PartParameterTest(InvenTreeAPITestCase): | |||||||
|         Test that we can create a param via the API |         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( |         response = self.client.post( | ||||||
|             url, |             url, | ||||||
| @@ -860,7 +860,7 @@ class PartParameterTest(InvenTreeAPITestCase): | |||||||
|         Tests for the PartParameter detail endpoint |         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) |         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/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+)/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'^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 = [ | part_detail_urls = [ | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ from rapidfuzz import fuzz | |||||||
| from decimal import Decimal, InvalidOperation | from decimal import Decimal, InvalidOperation | ||||||
|  |  | ||||||
| from .models import PartCategory, Part, PartRelated | from .models import PartCategory, Part, PartRelated | ||||||
| from .models import PartParameterTemplate, PartParameter | from .models import PartParameterTemplate | ||||||
| from .models import PartCategoryParameterTemplate | from .models import PartCategoryParameterTemplate | ||||||
| from .models import BomItem | from .models import BomItem | ||||||
| from .models import match_part_names | from .models import match_part_names | ||||||
| @@ -2257,78 +2257,6 @@ class PartParameterTemplateDelete(AjaxDeleteView): | |||||||
|     ajax_form_title = _("Delete Part Parameter Template") |     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): | class CategoryDetail(InvenTreeRoleMixin, DetailView): | ||||||
|     """ Detail view for PartCategory """ |     """ Detail view for PartCategory """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -100,7 +100,7 @@ class StockTest(TestCase): | |||||||
|         # And there should be *no* items being build |         # And there should be *no* items being build | ||||||
|         self.assertEqual(part.quantity_being_built, 0) |         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" |         # Add some stock items which are "building" | ||||||
|         for i in range(10): |         for i in range(10): | ||||||
|   | |||||||
| @@ -75,7 +75,7 @@ | |||||||
| {{ block.super }} | {{ block.super }} | ||||||
|  |  | ||||||
|     $("#param-table").inventreeTable({ |     $("#param-table").inventreeTable({ | ||||||
|         url: "{% url 'api-part-param-template-list' %}", |         url: "{% url 'api-part-parameter-template-list' %}", | ||||||
|         queryParams: { |         queryParams: { | ||||||
|             ordering: 'name', |             ordering: 'name', | ||||||
|         }, |         }, | ||||||
|   | |||||||
| @@ -1,34 +1,72 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
| {% load inventree_extras %} | {% 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={}) { | function newBuildOrder(options={}) { | ||||||
|     /* Launch modal form to create a new BuildOrder. |     /* Launch modal form to create a new BuildOrder. | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|     launchModalForm( |     var fields = buildFormFields(); | ||||||
|         "{% url 'build-create' %}", |  | ||||||
|         { |  | ||||||
|             follow: true, |  | ||||||
|             data: options.data || {}, |  | ||||||
|             callback: [ |  | ||||||
|                 { |  | ||||||
|                     field: 'part', |  | ||||||
|                     action: function(value) { |  | ||||||
|                         inventreeGet( |  | ||||||
|                             `/api/part/${value}/`, {}, |  | ||||||
|                             { |  | ||||||
|                                 success: function(response) { |  | ||||||
|  |  | ||||||
|                                     //enableField('serial_numbers', response.trackable); |     if (options.part) { | ||||||
|                                     //clearField('serial_numbers'); |         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 idx = $(this).closest('tr').attr('data-index'); | ||||||
|             var row = $(table).bootstrapTable('getData')[idx]; |             var row = $(table).bootstrapTable('getData')[idx]; | ||||||
|  |  | ||||||
|             // Launch form to create a new build order |             newBuildOrder({ | ||||||
|             launchModalForm('{% url "build-create" %}', { |                 part: pk, | ||||||
|                 follow: true, |                 parent: buildId, | ||||||
|                 data: { |                 quantity: requiredQuantity(row) - sumAllocations(row), | ||||||
|                     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 idx = $(this).closest('tr').attr('data-index'); | ||||||
|             var row = $(table).bootstrapTable('getData')[idx]; |             var row = $(table).bootstrapTable('getData')[idx]; | ||||||
|  |  | ||||||
|             // Launch form to create a new build order |             newBuildOrder({ | ||||||
|             launchModalForm('{% url "build-create" %}', { |                 part: pk, | ||||||
|                 follow: true, |                 parent: options.build, | ||||||
|                 data: { |  | ||||||
|                     part: pk, |  | ||||||
|                     parent: options.build, |  | ||||||
|                 } |  | ||||||
|             }); |             }); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -511,6 +511,10 @@ function insertConfirmButton(options) { | |||||||
|  */ |  */ | ||||||
| function submitFormData(fields, 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 |     // Form data to be uploaded to the server | ||||||
|     // Only used if file / image upload is required |     // Only used if file / image upload is required | ||||||
|     var form_data = new FormData(); |     var form_data = new FormData(); | ||||||
| @@ -728,11 +732,31 @@ function handleFormSuccess(response, options) { | |||||||
|  |  | ||||||
|     // Close the modal |     // Close the modal | ||||||
|     if (!options.preventClose) { |     if (!options.preventClose) { | ||||||
|         // TODO: Actually just *delete* the modal, |         // Note: The modal will be deleted automatically after closing | ||||||
|         // rather than hiding it!! |  | ||||||
|         $(options.modal).modal('hide'); |         $(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) { |     if (options.onSuccess) { | ||||||
|         // Callback function |         // Callback function | ||||||
|         options.onSuccess(response, options); |         options.onSuccess(response, options); | ||||||
| @@ -778,6 +802,9 @@ function clearFormErrors(options) { | |||||||
|  */ |  */ | ||||||
| function handleFormErrors(errors, fields, 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 |     // Remove any existing error messages from the form | ||||||
|     clearFormErrors(options); |     clearFormErrors(options); | ||||||
|  |  | ||||||
| @@ -1201,11 +1228,21 @@ function renderModelData(name, model, data, parameters, options) { | |||||||
|         case 'partcategory': |         case 'partcategory': | ||||||
|             renderer = renderPartCategory; |             renderer = renderPartCategory; | ||||||
|             break; |             break; | ||||||
|  |         case 'partparametertemplate': | ||||||
|  |             renderer = renderPartParameterTemplate; | ||||||
|  |             break; | ||||||
|         case 'supplierpart': |         case 'supplierpart': | ||||||
|             renderer = renderSupplierPart; |             renderer = renderSupplierPart; | ||||||
|             break; |             break; | ||||||
|  |         case 'build': | ||||||
|  |             renderer = renderBuild; | ||||||
|  |             break; | ||||||
|         case 'owner': |         case 'owner': | ||||||
|             renderer = renderOwner; |             renderer = renderOwner; | ||||||
|  |             break; | ||||||
|  |         case 'user': | ||||||
|  |             renderer = renderUser; | ||||||
|  |             break; | ||||||
|         default: |         default: | ||||||
|             break; |             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={}) { | function selectLabel(labels, items, options={}) { | ||||||
|     /** |     /** | ||||||
|      * Present the user with the available labels, |      * Present the user with the available labels, | ||||||
|   | |||||||
| @@ -83,8 +83,6 @@ function createNewModal(options={}) { | |||||||
|  |  | ||||||
|     // Capture "enter" key input |     // Capture "enter" key input | ||||||
|     $(modal_name).on('keydown', 'input', function(event) { |     $(modal_name).on('keydown', 'input', function(event) { | ||||||
|  |  | ||||||
|          |  | ||||||
|         if (event.keyCode == 13) { |         if (event.keyCode == 13) { | ||||||
|             event.preventDefault(); |             event.preventDefault(); | ||||||
|             // Simulate a click on the 'Submit' button |             // 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 | // Renderer for "Part" model | ||||||
| function renderPart(name, data, parameters, options) { | function renderPart(name, data, parameters, options) { | ||||||
|  |  | ||||||
| @@ -92,6 +113,18 @@ function renderPart(name, data, parameters, options) { | |||||||
|     return html; |     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 | // Renderer for "Owner" model | ||||||
| function renderOwner(name, data, parameters, options) { | 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 | // Rendered for "SupplierPart" model | ||||||
| function renderSupplierPart(name, data, parameters, options) { | 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={}) { | function loadParametricPartTable(table, options={}) { | ||||||
|     /* Load parametric table for part parameters |     /* Load parametric table for part parameters | ||||||
|      *  |      *  | ||||||
|   | |||||||
| @@ -87,6 +87,7 @@ class RuleSet(models.Model): | |||||||
|             'company_supplierpart', |             'company_supplierpart', | ||||||
|             'company_manufacturerpart', |             'company_manufacturerpart', | ||||||
|             'company_manufacturerpartparameter', |             'company_manufacturerpartparameter', | ||||||
|  |             'label_partlabel', | ||||||
|         ], |         ], | ||||||
|         'stock_location': [ |         'stock_location': [ | ||||||
|             'stock_stocklocation', |             'stock_stocklocation', | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user