From a1a4bddcc6f087891103fadf9747d6ef05c4e593 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Jul 2021 21:07:45 +1000 Subject: [PATCH 1/5] Add model for PartLabel --- .../migrations/0008_auto_20210708_2106.py | 37 ++++++++++ InvenTree/label/models.py | 69 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 InvenTree/label/migrations/0008_auto_20210708_2106.py 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..cfa658914d 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -12,6 +12,7 @@ import datetime from django.conf import settings from django.db import models +from django.db.models.fields import Field from django.urls import reverse from django.core.validators import FileExtensionValidator, MinValueValidator from django.core.exceptions import ValidationError, FieldError @@ -25,6 +26,8 @@ from InvenTree.helpers import validateFilterString, normalize import common.models import stock.models +import part.models + try: from django_weasyprint import WeasyTemplateResponseMixin @@ -59,6 +62,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 +256,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 +346,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), + } From 1830467487e476b715be9d210f8d3dc4740ff3e0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Jul 2021 22:10:10 +1000 Subject: [PATCH 2/5] Add admin / serializer / API for PartLabel model --- InvenTree/label/admin.py | 3 +- InvenTree/label/api.py | 124 ++++++++++++++++++++++++++++++++- InvenTree/label/models.py | 1 - InvenTree/label/serializers.py | 21 +++++- 4 files changed, 144 insertions(+), 5 deletions(-) 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..13f737057d 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 = parts.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/models.py b/InvenTree/label/models.py index cfa658914d..b558f10e73 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -12,7 +12,6 @@ import datetime from django.conf import settings from django.db import models -from django.db.models.fields import Field from django.urls import reverse from django.core.validators import FileExtensionValidator, MinValueValidator from django.core.exceptions import ValidationError, FieldError 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', + ] From c39f705ef75098c25fb147d3410a483dc194e2a9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Jul 2021 22:42:31 +1000 Subject: [PATCH 3/5] Copy default part label templates --- InvenTree/label/apps.py | 148 ++++++++++++++---- .../templates/label/part/part_label.html | 33 ++++ 2 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 InvenTree/label/templates/label/part/part_label.html diff --git a/InvenTree/label/apps.py b/InvenTree/label/apps.py index e51767d5f0..0293c3d18c 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': 50, + '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/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 From 15cb1e00059a0c4359cbcc1f40250d20877a22ee Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Jul 2021 22:54:41 +1000 Subject: [PATCH 4/5] Print part labels --- InvenTree/label/api.py | 2 +- InvenTree/label/apps.py | 2 +- InvenTree/part/templates/part/part_base.html | 4 ++ InvenTree/templates/js/label.js | 55 ++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 13f737057d..b2d17efdfe 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -394,7 +394,7 @@ class PartLabelMixin: for key in ['part', 'part[]', 'parts', 'parts[]']: if key in params: - parts = parts.getlist(key, []) + parts = params.getlist(key, []) break valid_ids = [] diff --git a/InvenTree/label/apps.py b/InvenTree/label/apps.py index 0293c3d18c..2556e11bca 100644 --- a/InvenTree/label/apps.py +++ b/InvenTree/label/apps.py @@ -255,7 +255,7 @@ class LabelConfig(AppConfig): 'file': 'part_label.html', 'name': 'Part Label', 'description': 'Simple part label', - 'width': 50, + 'width': 70, 'height': 24, }, ] 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/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, From bd4dde2cb741bcb67ddecdcb9425e8b68878d407 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Jul 2021 23:22:25 +1000 Subject: [PATCH 5/5] Add permissions for PartLabel model --- InvenTree/users/models.py | 1 + 1 file changed, 1 insertion(+) 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',