diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index f1b1c0c040..c22b39dc43 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -153,6 +153,11 @@ class InvenTreeMetadata(SimpleMetadata): if 'default' not in field_info and not field.default == empty: field_info['default'] = field.get_default() + # Force non-nullable fields to read as "required" + # (even if there is a default value!) + if not field.allow_null and not (hasattr(field, 'allow_blank') and field.allow_blank): + field_info['required'] = True + # Introspect writable related fields if field_info['type'] == 'field' and not field_info['read_only']: @@ -166,7 +171,12 @@ class InvenTreeMetadata(SimpleMetadata): if model: # Mark this field as "related", and point to the URL where we can get the data! field_info['type'] = 'related field' - field_info['api_url'] = model.get_api_url() field_info['model'] = model._meta.model_name + # Special case for 'user' model + if field_info['model'] == 'user': + field_info['api_url'] = '/api/user/' + else: + field_info['api_url'] = model.get_api_url() + return field_info diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 904a523a09..069b6e58fe 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -5,11 +5,13 @@ JSON API for the Build app # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django_filters.rest_framework import DjangoFilterBackend +from django.conf.urls import url, include + from rest_framework import filters from rest_framework import generics -from django.conf.urls import url, include +from django_filters.rest_framework import DjangoFilterBackend +from django_filters import rest_framework as rest_filters from InvenTree.api import AttachmentMixin from InvenTree.helpers import str2bool, isNull @@ -19,6 +21,36 @@ from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer +class BuildFilter(rest_filters.FilterSet): + """ + Custom filterset for BuildList API endpoint + """ + + status = rest_filters.NumberFilter(label='Status') + + active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') + + def filter_active(self, queryset, name, value): + + if str2bool(value): + queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES) + else: + queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES) + + return queryset + + overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue') + + def filter_overdue(self, queryset, name, value): + + if str2bool(value): + queryset = queryset.filter(Build.OVERDUE_FILTER) + else: + queryset = queryset.exclude(Build.OVERDUE_FILTER) + + return queryset + + class BuildList(generics.ListCreateAPIView): """ API endpoint for accessing a list of Build objects. @@ -28,6 +60,7 @@ class BuildList(generics.ListCreateAPIView): queryset = Build.objects.all() serializer_class = BuildSerializer + filterset_class = BuildFilter filter_backends = [ DjangoFilterBackend, @@ -97,34 +130,6 @@ class BuildList(generics.ListCreateAPIView): except (ValueError, Build.DoesNotExist): pass - # Filter by build status? - status = params.get('status', None) - - if status is not None: - queryset = queryset.filter(status=status) - - # Filter by "pending" status - active = params.get('active', None) - - if active is not None: - active = str2bool(active) - - if active: - queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES) - else: - queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES) - - # Filter by "overdue" status? - overdue = params.get('overdue', None) - - if overdue is not None: - overdue = str2bool(overdue) - - if overdue: - queryset = queryset.filter(Build.OVERDUE_FILTER) - else: - queryset = queryset.exclude(Build.OVERDUE_FILTER) - # Filter by associated part? part = params.get('part', None) diff --git a/InvenTree/build/migrations/0030_alter_build_reference.py b/InvenTree/build/migrations/0030_alter_build_reference.py new file mode 100644 index 0000000000..75f43c77dc --- /dev/null +++ b/InvenTree/build/migrations/0030_alter_build_reference.py @@ -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'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 848d774d1c..5f8af9096b 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -21,6 +21,7 @@ from django.core.validators import MinValueValidator from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey +from mptt.exceptions import InvalidMove from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode @@ -37,6 +38,35 @@ from part import models as PartModels from users import models as UserModels +def get_next_build_number(): + """ + Returns the next available BuildOrder reference number + """ + + if Build.objects.count() == 0: + return + + build = Build.objects.exclude(reference=None).last() + + attempts = set([build.reference]) + + reference = build.reference + + while 1: + reference = increment(reference) + + if reference in attempts: + # Escape infinite recursion + return reference + + if Build.objects.filter(reference=reference).exists(): + attempts.add(reference) + else: + break + + return reference + + class Build(MPTTModel): """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. @@ -60,11 +90,20 @@ class Build(MPTTModel): responsible: User (or group) responsible for completing the build """ + OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) + @staticmethod def get_api_url(): return reverse('api-build-list') - OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) + def save(self, *args, **kwargs): + + try: + super().save(*args, **kwargs) + except InvalidMove: + raise ValidationError({ + 'parent': _('Invalid choice for parent build'), + }) class Meta: verbose_name = _("Build Order") @@ -130,6 +169,7 @@ class Build(MPTTModel): blank=False, help_text=_('Build Order Reference'), verbose_name=_('Reference'), + default=get_next_build_number, validators=[ validate_build_order_reference ] diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 363614035a..5c0fced884 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -62,7 +62,7 @@ class BuildSerializer(InvenTreeModelSerializer): return queryset def __init__(self, *args, **kwargs): - part_detail = kwargs.pop('part_detail', False) + part_detail = kwargs.pop('part_detail', True) super().__init__(*args, **kwargs) @@ -75,9 +75,12 @@ class BuildSerializer(InvenTreeModelSerializer): 'pk', 'url', 'title', + 'batch', 'creation_date', 'completed', 'completion_date', + 'destination', + 'parent', 'part', 'part_detail', 'overdue', @@ -87,6 +90,7 @@ class BuildSerializer(InvenTreeModelSerializer): 'status', 'status_text', 'target_date', + 'take_from', 'notes', 'link', 'issued_by', diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 2376daf0cf..ece6de36bb 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -196,10 +196,7 @@ src="{% static 'img/blank_image.png' %}" }); $("#build-edit").click(function () { - launchModalForm("{% url 'build-edit' build.id %}", - { - reload: true - }); + editBuildOrder({{ build.pk }}); }); $("#build-cancel").click(function() { diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index a3b69646dd..b572feb14b 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -5,10 +5,11 @@ from django.test import TestCase from django.core.exceptions import ValidationError from django.db.utils import IntegrityError -from build.models import Build, BuildItem +from InvenTree import status_codes as status + +from build.models import Build, BuildItem, get_next_build_number from stock.models import StockItem from part.models import Part, BomItem -from InvenTree import status_codes as status class BuildTest(TestCase): @@ -80,8 +81,14 @@ class BuildTest(TestCase): quantity=2 ) + ref = get_next_build_number() + + if ref is None: + ref = "0001" + # Create a "Build" object to make 10x objects self.build = Build.objects.create( + reference=ref, title="This is a build", part=self.assembly, quantity=10 diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index 9a440e0b93..9c5134cc66 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -252,23 +252,6 @@ class TestBuildViews(TestCase): self.assertIn(build.title, content) - def test_build_create(self): - """ Test the build creation view (ajax form) """ - - url = reverse('build-create') - - # Create build without specifying part - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create build with valid part - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create build with invalid part - response = self.client.get(url, {'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - def test_build_allocate(self): """ Test the part allocation view for a Build """ diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 549a20ee7e..c354a17ac7 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -7,7 +7,6 @@ from django.conf.urls import url, include from . import views build_detail_urls = [ - url(r'^edit/', views.BuildUpdate.as_view(), name='build-edit'), url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'), url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), @@ -36,8 +35,6 @@ build_urls = [ url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), ])), - url(r'new/', views.BuildCreate.as_view(), name='build-create'), - url(r'^(?P\d+)/', include(build_detail_urls)), url(r'.*$', views.BuildIndex.as_view(), name='build-index'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 16004dacc1..2bae825b0f 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -667,126 +667,6 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView): return context -class BuildCreate(AjaxCreateView): - """ - View to create a new Build object - """ - - model = Build - context_object_name = 'build' - form_class = forms.EditBuildForm - ajax_form_title = _('New Build Order') - ajax_template_name = 'modal_form.html' - - def get_form(self): - form = super().get_form() - - if form['part'].value(): - form.fields['part'].widget = HiddenInput() - - return form - - def get_initial(self): - """ Get initial parameters for Build creation. - - If 'part' is specified in the GET query, initialize the Build with the specified Part - """ - - initials = super(BuildCreate, self).get_initial().copy() - - initials['parent'] = self.request.GET.get('parent', None) - - # User has provided a SalesOrder ID - initials['sales_order'] = self.request.GET.get('sales_order', None) - - initials['quantity'] = self.request.GET.get('quantity', 1) - - part = self.request.GET.get('part', None) - - if part: - - try: - part = Part.objects.get(pk=part) - # User has provided a Part ID - initials['part'] = part - initials['destination'] = part.get_default_location() - - to_order = part.quantity_to_order - - if to_order < 1: - to_order = 1 - - initials['quantity'] = to_order - except (ValueError, Part.DoesNotExist): - pass - - initials['reference'] = Build.getNextBuildNumber() - - # Pre-fill the issued_by user - initials['issued_by'] = self.request.user - - return initials - - def get_data(self): - return { - 'success': _('Created new build'), - } - - def validate(self, build, form, **kwargs): - """ - Perform extra form validation. - - - If part is trackable, check that either batch or serial numbers are calculated - - By this point form.is_valid() has been executed - """ - - pass - - -class BuildUpdate(AjaxUpdateView): - """ View for editing a Build object """ - - model = Build - form_class = forms.EditBuildForm - context_object_name = 'build' - ajax_form_title = _('Edit Build Order Details') - ajax_template_name = 'modal_form.html' - - def get_form(self): - - form = super().get_form() - - build = self.get_object() - - # Fields which are included in the form, but hidden - hidden = [ - 'parent', - 'sales_order', - ] - - if build.is_complete: - # Fields which cannot be edited once the build has been completed - - hidden += [ - 'part', - 'quantity', - 'batch', - 'take_from', - 'destination', - ] - - for field in hidden: - form.fields[field].widget = HiddenInput() - - return form - - def get_data(self): - return { - 'info': _('Edited build'), - } - - class BuildDelete(AjaxDeleteView): """ View to delete a build """ diff --git a/InvenTree/label/admin.py b/InvenTree/label/admin.py index 2e4967ffc2..8fee2b1f8f 100644 --- a/InvenTree/label/admin.py +++ b/InvenTree/label/admin.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.contrib import admin -from .models import StockItemLabel, StockLocationLabel +from .models import StockItemLabel, StockLocationLabel, PartLabel class LabelAdmin(admin.ModelAdmin): @@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin): admin.site.register(StockItemLabel, LabelAdmin) admin.site.register(StockLocationLabel, LabelAdmin) +admin.site.register(PartLabel, LabelAdmin) diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 8522857e30..b2d17efdfe 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -15,9 +15,10 @@ import InvenTree.helpers import common.models from stock.models import StockItem, StockLocation +from part.models import Part -from .models import StockItemLabel, StockLocationLabel -from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer +from .models import StockItemLabel, StockLocationLabel, PartLabel +from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer class LabelListView(generics.ListAPIView): @@ -132,6 +133,7 @@ class StockItemLabelMixin: for key in ['item', 'item[]', 'items', 'items[]']: if key in params: items = params.getlist(key, []) + break valid_ids = [] @@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, return self.print(request, locations) +class PartLabelMixin: + """ + Mixin for extracting Part objects from query parameters + """ + + def get_parts(self): + """ + Return a list of requested Part objects + """ + + parts = [] + + params = self.request.query_params + + for key in ['part', 'part[]', 'parts', 'parts[]']: + if key in params: + parts = params.getlist(key, []) + break + + valid_ids = [] + + for part in parts: + try: + valid_ids.append(int(part)) + except (ValueError): + pass + + # List of Part objects which match provided values + return Part.objects.filter(pk__in=valid_ids) + + +class PartLabelList(LabelListView, PartLabelMixin): + """ + API endpoint for viewing list of PartLabel objects + """ + + queryset = PartLabel.objects.all() + serializer_class = PartLabelSerializer + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + parts = self.get_parts() + + if len(parts) > 0: + + valid_label_ids = set() + + for label in queryset.all(): + + matches = True + + try: + filters = InvenTree.helpers.validateFilterString(label.filters) + except ValidationError: + continue + + for part in parts: + + part_query = Part.objects.filter(pk=part.pk) + + try: + if not part_query.filter(**filters).exists(): + matches = False + break + except FieldError: + matches = False + break + + if matches: + valid_label_ids.add(label.pk) + + # Reduce queryset to only valid matches + queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids]) + + return queryset + + +class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for a single PartLabel object + """ + + queryset = PartLabel.objects.all() + serializer_class = PartLabelSerializer + + +class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin): + """ + API endpoint for printing a PartLabel object + """ + + queryset = PartLabel.objects.all() + serializer_class = PartLabelSerializer + + def get(self, request, *args, **kwargs): + """ + Check if valid part(s) have been provided + """ + + parts = self.get_parts() + + return self.print(request, parts) + + label_api_urls = [ # Stock item labels @@ -401,4 +509,16 @@ label_api_urls = [ # List view url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'), ])), + + # Part labels + url(r'^part/', include([ + # Detail views + url(r'^(?P\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'), + ])), ] diff --git a/InvenTree/label/apps.py b/InvenTree/label/apps.py index e51767d5f0..2556e11bca 100644 --- a/InvenTree/label/apps.py +++ b/InvenTree/label/apps.py @@ -37,6 +37,7 @@ class LabelConfig(AppConfig): if canAppAccessDatabase(): self.create_stock_item_labels() self.create_stock_location_labels() + self.create_part_labels() def create_stock_item_labels(self): """ @@ -65,7 +66,7 @@ class LabelConfig(AppConfig): ) if not os.path.exists(dst_dir): - logger.info(f"Creating missing directory: '{dst_dir}'") + logger.info(f"Creating required directory: '{dst_dir}'") os.makedirs(dst_dir, exist_ok=True) labels = [ @@ -109,24 +110,21 @@ class LabelConfig(AppConfig): logger.info(f"Copying label template '{dst_file}'") shutil.copyfile(src_file, dst_file) - try: - # Check if a label matching the template already exists - if StockItemLabel.objects.filter(label=filename).exists(): - continue + # Check if a label matching the template already exists + if StockItemLabel.objects.filter(label=filename).exists(): + continue - logger.info(f"Creating entry for StockItemLabel '{label['name']}'") + logger.info(f"Creating entry for StockItemLabel '{label['name']}'") - StockItemLabel.objects.create( - name=label['name'], - description=label['description'], - label=filename, - filters='', - enabled=True, - width=label['width'], - height=label['height'], - ) - except: - pass + StockItemLabel.objects.create( + name=label['name'], + description=label['description'], + label=filename, + filters='', + enabled=True, + width=label['width'], + height=label['height'], + ) def create_stock_location_labels(self): """ @@ -155,7 +153,7 @@ class LabelConfig(AppConfig): ) if not os.path.exists(dst_dir): - logger.info(f"Creating missing directory: '{dst_dir}'") + logger.info(f"Creating required directory: '{dst_dir}'") os.makedirs(dst_dir, exist_ok=True) labels = [ @@ -206,21 +204,103 @@ class LabelConfig(AppConfig): logger.info(f"Copying label template '{dst_file}'") shutil.copyfile(src_file, dst_file) - try: - # Check if a label matching the template already exists - if StockLocationLabel.objects.filter(label=filename).exists(): - continue + # Check if a label matching the template already exists + if StockLocationLabel.objects.filter(label=filename).exists(): + continue - logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") + logger.info(f"Creating entry for StockLocationLabel '{label['name']}'") - StockLocationLabel.objects.create( - name=label['name'], - description=label['description'], - label=filename, - filters='', - enabled=True, - width=label['width'], - height=label['height'], - ) - except: - pass + StockLocationLabel.objects.create( + name=label['name'], + description=label['description'], + label=filename, + filters='', + enabled=True, + width=label['width'], + height=label['height'], + ) + + def create_part_labels(self): + """ + Create database entries for the default PartLabel templates, + if they do not already exist. + """ + + try: + from .models import PartLabel + except: + # Database might not yet be ready + return + + src_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'templates', + 'label', + 'part', + ) + + dst_dir = os.path.join( + settings.MEDIA_ROOT, + 'label', + 'inventree', + 'part', + ) + + if not os.path.exists(dst_dir): + logger.info(f"Creating required directory: '{dst_dir}'") + os.makedirs(dst_dir, exist_ok=True) + + labels = [ + { + 'file': 'part_label.html', + 'name': 'Part Label', + 'description': 'Simple part label', + 'width': 70, + 'height': 24, + }, + ] + + for label in labels: + + filename = os.path.join( + 'label', + 'inventree', + 'part', + label['file'] + ) + + src_file = os.path.join(src_dir, label['file']) + dst_file = os.path.join(settings.MEDIA_ROOT, filename) + + to_copy = False + + if os.path.exists(dst_file): + # File already exists - let's see if it is the "same" + + if not hashFile(dst_file) == hashFile(src_file): + logger.info(f"Hash differs for '{filename}'") + to_copy = True + + else: + logger.info(f"Label template '{filename}' is not present") + to_copy = True + + if to_copy: + logger.info(f"Copying label template '{dst_file}'") + shutil.copyfile(src_file, dst_file) + + # Check if a label matching the template already exists + if PartLabel.objects.filter(label=filename).exists(): + continue + + logger.info(f"Creating entry for PartLabel '{label['name']}'") + + PartLabel.objects.create( + name=label['name'], + description=label['description'], + label=filename, + filters='', + enabled=True, + width=label['width'], + height=label['height'], + ) diff --git a/InvenTree/label/migrations/0008_auto_20210708_2106.py b/InvenTree/label/migrations/0008_auto_20210708_2106.py new file mode 100644 index 0000000000..ea57526909 --- /dev/null +++ b/InvenTree/label/migrations/0008_auto_20210708_2106.py @@ -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'), + ), + ] diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 8a6684d7e3..b558f10e73 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -25,6 +25,8 @@ from InvenTree.helpers import validateFilterString, normalize import common.models import stock.models +import part.models + try: from django_weasyprint import WeasyTemplateResponseMixin @@ -59,6 +61,13 @@ def validate_stock_location_filters(filters): return filters +def validate_part_filters(filters): + + filters = validateFilterString(filters, model=part.models.Part) + + return filters + + class WeasyprintLabelMixin(WeasyTemplateResponseMixin): """ Class for rendering a label to a PDF @@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate): filters = models.CharField( blank=True, max_length=250, - help_text=_('Query filters (comma-separated list of key=value pairs'), + help_text=_('Query filters (comma-separated list of key=value pairs),'), verbose_name=_('Filters'), validators=[ - validate_stock_item_filters] + validate_stock_item_filters + ] ) def matches_stock_item(self, item): @@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate): 'location': location, 'qr_data': location.format_barcode(brief=True), } + + +class PartLabel(LabelTemplate): + """ + Template for printing Part labels + """ + + @staticmethod + def get_api_url(): + return reverse('api-part-label-list') + + SUBDIR = 'part' + + filters = models.CharField( + blank=True, max_length=250, + help_text=_('Part query filters (comma-separated value of key=value pairs)'), + verbose_name=_('Filters'), + validators=[ + validate_part_filters + ] + ) + + def matches_part(self, part): + """ + Test if this label template matches a given Part object + """ + + try: + filters = validateFilterString(self.filters) + parts = part.models.Part.objects.filter(**filters) + except (ValidationError, FieldError): + return False + + parts = parts.filter(pk=part.pk) + + return parts.exists() + + def get_context_data(self, request): + """ + Generate context data for each provided Part object + """ + + part = self.object_to_print + + return { + 'part': part, + 'category': part.category, + 'name': part.name, + 'description': part.description, + 'IPN': part.IPN, + 'revision': part.revision, + 'qr_data': part.format_barcode(brief=True), + 'qr_url': part.format_barcode(url=True, request=request), + } diff --git a/InvenTree/label/serializers.py b/InvenTree/label/serializers.py index c9d487af23..47ccd51ba1 100644 --- a/InvenTree/label/serializers.py +++ b/InvenTree/label/serializers.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField -from .models import StockItemLabel, StockLocationLabel +from .models import StockItemLabel, StockLocationLabel, PartLabel class StockItemLabelSerializer(InvenTreeModelSerializer): @@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer): 'filters', 'enabled', ] + + +class PartLabelSerializer(InvenTreeModelSerializer): + """ + Serializes a PartLabel object + """ + + label = InvenTreeAttachmentSerializerField(required=True) + + class Meta: + model = PartLabel + fields = [ + 'pk', + 'name', + 'description', + 'label', + 'filters', + 'enabled', + ] diff --git a/InvenTree/label/templates/label/part/part_label.html b/InvenTree/label/templates/label/part/part_label.html new file mode 100644 index 0000000000..558e1bca5b --- /dev/null +++ b/InvenTree/label/templates/label/part/part_label.html @@ -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 %} + + + +
+ {{ part.full_name }} +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 22f70d0f3c..99c71d1c3e 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -43,8 +43,10 @@ def get_next_po_number(): attempts = set([order.reference]) + reference = order.reference + while 1: - reference = increment(order.reference) + reference = increment(reference) if reference in attempts: # Escape infinite recursion @@ -70,8 +72,10 @@ def get_next_so_number(): attempts = set([order.reference]) + reference = order.reference + while 1: - reference = increment(order.reference) + reference = increment(reference) if reference in attempts: # Escape infinite recursion diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 7303f06787..60bf360f73 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -425,18 +425,18 @@ class PartFilter(rest_filters.FilterSet): else: queryset = queryset.filter(IPN='') + # Regex filter for name + name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') + # Exact match for IPN - ipn = rest_filters.CharFilter( + IPN = rest_filters.CharFilter( label='Filter by exact IPN (internal part number)', field_name='IPN', lookup_expr="iexact" ) # Regex match for IPN - ipn_regex = rest_filters.CharFilter( - label='Filter by regex on IPN (internal part number) field', - field_name='IPN', lookup_expr='iregex' - ) + IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex') # low_stock filter low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock') @@ -1115,10 +1115,10 @@ part_api_urls = [ # Base URL for PartParameter API endpoints url(r'^parameter/', include([ - url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'), + url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), - url(r'^(?P\d+)/', PartParameterDetail.as_view(), name='api-part-param-detail'), - url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'), + url(r'^(?P\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), + url(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), ])), url(r'^thumbs/', include([ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index cc533177d9..8fe5744f06 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2164,7 +2164,7 @@ class PartParameterTemplate(models.Model): @staticmethod def get_api_url(): - return reverse('api-part-param-template-list') + return reverse('api-part-parameter-template-list') def __str__(self): s = str(self.name) @@ -2205,7 +2205,7 @@ class PartParameter(models.Model): @staticmethod def get_api_url(): - return reverse('api-part-param-list') + return reverse('api-part-parameter-list') def __str__(self): # String representation of a PartParameter (used in the admin interface) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 313e2cf920..6627639bca 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -508,19 +508,6 @@ class BomItemSerializer(InvenTreeModelSerializer): ] -class PartParameterSerializer(InvenTreeModelSerializer): - """ JSON serializers for the PartParameter model """ - - class Meta: - model = PartParameter - fields = [ - 'pk', - 'part', - 'template', - 'data' - ] - - class PartParameterTemplateSerializer(InvenTreeModelSerializer): """ JSON serializer for the PartParameterTemplate model """ @@ -533,6 +520,22 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer): ] +class PartParameterSerializer(InvenTreeModelSerializer): + """ JSON serializers for the PartParameter model """ + + template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True) + + class Meta: + model = PartParameter + fields = [ + 'pk', + 'part', + 'template', + 'template_detail', + 'data' + ] + + class CategoryParameterTemplateSerializer(InvenTreeModelSerializer): """ Serializer for PartCategoryParameterTemplate """ diff --git a/InvenTree/part/templates/part/build.html b/InvenTree/part/templates/part/build.html index 4c28bef3d4..29f32c770a 100644 --- a/InvenTree/part/templates/part/build.html +++ b/InvenTree/part/templates/part/build.html @@ -34,9 +34,7 @@ {{ block.super }} $("#start-build").click(function() { newBuildOrder({ - data: { - part: {{ part.id }}, - } + part: {{ part.pk }}, }); }); diff --git a/InvenTree/part/templates/part/param_delete.html b/InvenTree/part/templates/part/param_delete.html deleted file mode 100644 index efb8ca3c26..0000000000 --- a/InvenTree/part/templates/part/param_delete.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "modal_delete_form.html" %} - -{% block pre_form_content %} -Are you sure you want to remove this parameter? -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/params.html b/InvenTree/part/templates/part/params.html index e1c21cd681..365003b052 100644 --- a/InvenTree/part/templates/part/params.html +++ b/InvenTree/part/templates/part/params.html @@ -21,54 +21,43 @@ - - - - - - - - - - {% for param in part.get_parameters %} - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Value" %}{% trans "Units" %}
{{ param.template.name }}{{ param.data }} - {{ param.template.units }} -
- {% if roles.part.change %} - - {% endif %} - {% if roles.part.change %} - - {% endif %} -
-
- +
{% endblock %} {% block js_ready %} {{ block.super }} + loadPartParameterTable( + '#parameter-table', + '{% url "api-part-parameter-list" %}', + { + params: { + part: {{ part.pk }}, + } + } + ); + $('#param-table').inventreeTable({ }); {% if roles.part.add %} $('#param-create').click(function() { - launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", { - reload: true, - secondary: [{ - field: 'template', - label: '{% trans "New Template" %}', - title: '{% trans "Create New Parameter Template" %}', - url: "{% url 'part-param-template-create' %}" - }], + + constructForm('{% url "api-part-parameter-list" %}', { + method: 'POST', + fields: { + part: { + value: {{ part.pk }}, + hidden: true, + }, + template: {}, + data: {}, + }, + title: '{% trans "Add Parameter" %}', + onSuccess: function() { + $('#parameter-table').bootstrapTable('refresh'); + } }); }); {% endif %} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index ee9d541762..53ab0aaf14 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -268,6 +268,10 @@ ); }); + $('#print-label').click(function() { + printPartLabels([{{ part.pk }}]); + }); + $("#part-count").click(function() { launchModalForm("/stock/adjust/", { data: { diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 0c1f083383..7700c5c61f 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -805,7 +805,7 @@ class PartParameterTest(InvenTreeAPITestCase): Test for listing part parameters """ - url = reverse('api-part-param-list') + url = reverse('api-part-parameter-list') response = self.client.get(url, format='json') @@ -838,7 +838,7 @@ class PartParameterTest(InvenTreeAPITestCase): Test that we can create a param via the API """ - url = reverse('api-part-param-list') + url = reverse('api-part-parameter-list') response = self.client.post( url, @@ -860,7 +860,7 @@ class PartParameterTest(InvenTreeAPITestCase): Tests for the PartParameter detail endpoint """ - url = reverse('api-part-param-detail', kwargs={'pk': 5}) + url = reverse('api-part-parameter-detail', kwargs={'pk': 5}) response = self.client.get(url) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 96560a7ad7..6bd8d02601 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -33,10 +33,6 @@ part_parameter_urls = [ url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), url(r'^template/(?P\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'), url(r'^template/(?P\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\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'), - url(r'^(?P\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'), ] part_detail_urls = [ diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index d9f79262d1..d349557816 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -32,7 +32,7 @@ from rapidfuzz import fuzz from decimal import Decimal, InvalidOperation from .models import PartCategory, Part, PartRelated -from .models import PartParameterTemplate, PartParameter +from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate from .models import BomItem from .models import match_part_names @@ -2257,78 +2257,6 @@ class PartParameterTemplateDelete(AjaxDeleteView): ajax_form_title = _("Delete Part Parameter Template") -class PartParameterCreate(AjaxCreateView): - """ View for creating a new PartParameter """ - - model = PartParameter - form_class = part_forms.EditPartParameterForm - ajax_form_title = _('Create Part Parameter') - - def get_initial(self): - - initials = {} - - part_id = self.request.GET.get('part', None) - - if part_id: - try: - initials['part'] = Part.objects.get(pk=part_id) - except (Part.DoesNotExist, ValueError): - pass - - return initials - - def get_form(self): - """ Return the form object. - - - Hide the 'Part' field (specified in URL) - - Limit the 'Template' options (to avoid duplicates) - """ - - form = super().get_form() - - part_id = self.request.GET.get('part', None) - - if part_id: - try: - part = Part.objects.get(pk=part_id) - - form.fields['part'].widget = HiddenInput() - - query = form.fields['template'].queryset - - query = query.exclude(id__in=[param.template.id for param in part.parameters.all()]) - - form.fields['template'].queryset = query - - except (Part.DoesNotExist, ValueError): - pass - - return form - - -class PartParameterEdit(AjaxUpdateView): - """ View for editing a PartParameter """ - - model = PartParameter - form_class = part_forms.EditPartParameterForm - ajax_form_title = _('Edit Part Parameter') - - def get_form(self): - - form = super().get_form() - - return form - - -class PartParameterDelete(AjaxDeleteView): - """ View for deleting a PartParameter """ - - model = PartParameter - ajax_template_name = 'part/param_delete.html' - ajax_form_title = _('Delete Part Parameter') - - class CategoryDetail(InvenTreeRoleMixin, DetailView): """ Detail view for PartCategory """ diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 6bc15b3505..205fb417a8 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -100,7 +100,7 @@ class StockTest(TestCase): # And there should be *no* items being build self.assertEqual(part.quantity_being_built, 0) - build = Build.objects.create(part=part, title='A test build', quantity=1) + build = Build.objects.create(reference='12345', part=part, title='A test build', quantity=1) # Add some stock items which are "building" for i in range(10): diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index d4b386e77f..57ffc95ba8 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -75,7 +75,7 @@ {{ block.super }} $("#param-table").inventreeTable({ - url: "{% url 'api-part-param-template-list' %}", + url: "{% url 'api-part-parameter-template-list' %}", queryParams: { ordering: 'name', }, diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index 67961d1b73..e284a0e8c8 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -1,34 +1,72 @@ {% load i18n %} {% load inventree_extras %} + +function buildFormFields() { + return { + reference: { + prefix: "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}", + }, + title: {}, + part: {}, + quantity: {}, + parent: { + filters: { + part_detail: true, + } + }, + batch: {}, + target_date: {}, + take_from: {}, + destination: {}, + link: { + icon: 'fa-link', + }, + issued_by: { + icon: 'fa-user', + }, + responsible: { + icon: 'fa-users', + }, + }; +} + + +function editBuildOrder(pk, options={}) { + + var fields = buildFormFields(); + + constructForm(`/api/build/${pk}/`, { + fields: fields, + reload: true, + title: '{% trans "Edit Build Order" %}', + }); +} + function newBuildOrder(options={}) { /* Launch modal form to create a new BuildOrder. */ - launchModalForm( - "{% url 'build-create' %}", - { - follow: true, - data: options.data || {}, - callback: [ - { - field: 'part', - action: function(value) { - inventreeGet( - `/api/part/${value}/`, {}, - { - success: function(response) { + var fields = buildFormFields(); - //enableField('serial_numbers', response.trackable); - //clearField('serial_numbers'); - } - } - ); - }, - } - ], - } - ) + if (options.part) { + fields.part.value = options.part; + } + + if (options.quantity) { + fields.quantity.value = options.quantity; + } + + if (options.parent) { + fields.parent.value = options.parent; + } + + constructForm(`/api/build/`, { + fields: fields, + follow: true, + method: 'POST', + title: '{% trans "Create Build Order" %}' + }); } @@ -384,14 +422,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var idx = $(this).closest('tr').attr('data-index'); var row = $(table).bootstrapTable('getData')[idx]; - // Launch form to create a new build order - launchModalForm('{% url "build-create" %}', { - follow: true, - data: { - part: pk, - parent: buildId, - quantity: requiredQuantity(row) - sumAllocations(row), - } + newBuildOrder({ + part: pk, + parent: buildId, + quantity: requiredQuantity(row) - sumAllocations(row), }); }); @@ -1092,13 +1126,9 @@ function loadBuildPartsTable(table, options={}) { var idx = $(this).closest('tr').attr('data-index'); var row = $(table).bootstrapTable('getData')[idx]; - // Launch form to create a new build order - launchModalForm('{% url "build-create" %}', { - follow: true, - data: { - part: pk, - parent: options.build, - } + newBuildOrder({ + part: pk, + parent: options.build, }); }); } diff --git a/InvenTree/templates/js/forms.js b/InvenTree/templates/js/forms.js index ac3bcefd04..b7af665393 100644 --- a/InvenTree/templates/js/forms.js +++ b/InvenTree/templates/js/forms.js @@ -511,6 +511,10 @@ function insertConfirmButton(options) { */ function submitFormData(fields, options) { + // Immediately disable the "submit" button, + // to prevent the form being submitted multiple times! + $(options.modal).find('#modal-form-submit').prop('disabled', true); + // Form data to be uploaded to the server // Only used if file / image upload is required var form_data = new FormData(); @@ -728,11 +732,31 @@ function handleFormSuccess(response, options) { // Close the modal if (!options.preventClose) { - // TODO: Actually just *delete* the modal, - // rather than hiding it!! + // Note: The modal will be deleted automatically after closing $(options.modal).modal('hide'); } + // Display any required messages + // Should we show alerts immediately or cache them? + var cache = (options.follow && response.url) || options.redirect || options.reload; + + // Display any messages + if (response && response.success) { + showAlertOrCache("alert-success", response.success, cache); + } + + if (response && response.info) { + showAlertOrCache("alert-info", response.info, cache); + } + + if (response && response.warning) { + showAlertOrCache("alert-warning", response.warning, cache); + } + + if (response && response.danger) { + showAlertOrCache("alert-danger", response.danger, cache); + } + if (options.onSuccess) { // Callback function options.onSuccess(response, options); @@ -778,6 +802,9 @@ function clearFormErrors(options) { */ function handleFormErrors(errors, fields, options) { + // Reset the status of the "submit" button + $(options.modal).find('#modal-form-submit').prop('disabled', false); + // Remove any existing error messages from the form clearFormErrors(options); @@ -1201,11 +1228,21 @@ function renderModelData(name, model, data, parameters, options) { case 'partcategory': renderer = renderPartCategory; break; + case 'partparametertemplate': + renderer = renderPartParameterTemplate; + break; case 'supplierpart': renderer = renderSupplierPart; break; + case 'build': + renderer = renderBuild; + break; case 'owner': renderer = renderOwner; + break; + case 'user': + renderer = renderUser; + break; default: break; } diff --git a/InvenTree/templates/js/label.js b/InvenTree/templates/js/label.js index dab9c6dcfa..dc9e8fa935 100644 --- a/InvenTree/templates/js/label.js +++ b/InvenTree/templates/js/label.js @@ -105,6 +105,61 @@ function printStockLocationLabels(locations, options={}) { } +function printPartLabels(parts, options={}) { + /** + * Print labels for the provided parts + */ + + if (parts.length == 0) { + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "Part(s) must be selected before printing labels" %}', + ); + + return; + } + + // Request available labels from the server + inventreeGet( + '{% url "api-part-label-list" %}', + { + enabled: true, + parts: parts, + }, + { + success: function(response) { + + if (response.length == 0) { + showAlertDialog( + '{% trans "No Labels Found" %}', + '{% trans "No labels found which match the selected part(s)" %}', + ); + + return; + } + + // Select label to print + selectLabel( + response, + parts, + { + success: function(pk) { + var url = `/api/label/part/${pk}/print/?`; + + parts.forEach(function(part) { + url += `parts[]=${part}&`; + }); + + window.location.href = url; + } + } + ); + } + } + ); +} + + function selectLabel(labels, items, options={}) { /** * Present the user with the available labels, diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index d0f9f742f8..b613ed81f6 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -83,8 +83,6 @@ function createNewModal(options={}) { // Capture "enter" key input $(modal_name).on('keydown', 'input', function(event) { - - if (event.keyCode == 13) { event.preventDefault(); // Simulate a click on the 'Submit' button diff --git a/InvenTree/templates/js/model_renderers.js b/InvenTree/templates/js/model_renderers.js index 5b838f184b..9e98199bfa 100644 --- a/InvenTree/templates/js/model_renderers.js +++ b/InvenTree/templates/js/model_renderers.js @@ -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 = ``; + + html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; + html += `{% trans "Build ID" %}: ${data.pk}`; + + html += `

${data.title}

`; + + return html; +} + + // Renderer for "Part" model function renderPart(name, data, parameters, options) { @@ -92,6 +113,18 @@ function renderPart(name, data, parameters, options) { return html; } +// Renderer for "User" model +function renderUser(name, data, parameters, options) { + + var html = `${data.username}`; + + if (data.first_name && data.last_name) { + html += ` - ${data.first_name} ${data.last_name}`; + } + + return html; +} + // Renderer for "Owner" model function renderOwner(name, data, parameters, options) { @@ -133,6 +166,14 @@ function renderPartCategory(name, data, parameters, options) { } +function renderPartParameterTemplate(name, data, parameters, options) { + + var html = `${data.name} - [${data.units}]`; + + return html; +} + + // Rendered for "SupplierPart" model function renderSupplierPart(name, data, parameters, options) { diff --git a/InvenTree/templates/js/part.js b/InvenTree/templates/js/part.js index 7fa63098e1..e106098ad4 100644 --- a/InvenTree/templates/js/part.js +++ b/InvenTree/templates/js/part.js @@ -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 = `
`; + + 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 += `
`; + + return html; + } + } + ], + onPostBody: function() { + // Setup button callbacks + $(table).find('.button-parameter-edit').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/part/parameter/${pk}/`, { + fields: { + data: {}, + }, + title: '{% trans "Edit Parameter" %}', + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + + $(table).find('.button-parameter-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/part/parameter/${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Parameter" %}', + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + } + }); +} + + function loadParametricPartTable(table, options={}) { /* Load parametric table for part parameters * diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index fda1830796..73bc5b6695 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -87,6 +87,7 @@ class RuleSet(models.Model): 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', + 'label_partlabel', ], 'stock_location': [ 'stock_stocklocation',