mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	| @@ -3,7 +3,7 @@ from __future__ import unicode_literals | ||||
|  | ||||
| from django.contrib import admin | ||||
|  | ||||
| from .models import StockItemLabel, StockLocationLabel | ||||
| from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||
|  | ||||
|  | ||||
| class LabelAdmin(admin.ModelAdmin): | ||||
| @@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin): | ||||
|  | ||||
| admin.site.register(StockItemLabel, LabelAdmin) | ||||
| admin.site.register(StockLocationLabel, LabelAdmin) | ||||
| admin.site.register(PartLabel, LabelAdmin) | ||||
|   | ||||
| @@ -15,9 +15,10 @@ import InvenTree.helpers | ||||
| import common.models | ||||
|  | ||||
| from stock.models import StockItem, StockLocation | ||||
| from part.models import Part | ||||
|  | ||||
| from .models import StockItemLabel, StockLocationLabel | ||||
| from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer | ||||
| from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||
| from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer | ||||
|  | ||||
|  | ||||
| class LabelListView(generics.ListAPIView): | ||||
| @@ -132,6 +133,7 @@ class StockItemLabelMixin: | ||||
|         for key in ['item', 'item[]', 'items', 'items[]']: | ||||
|             if key in params: | ||||
|                 items = params.getlist(key, []) | ||||
|                 break | ||||
|  | ||||
|         valid_ids = [] | ||||
|  | ||||
| @@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, | ||||
|         return self.print(request, locations) | ||||
|  | ||||
|  | ||||
| class PartLabelMixin: | ||||
|     """ | ||||
|     Mixin for extracting Part objects from query parameters | ||||
|     """ | ||||
|  | ||||
|     def get_parts(self): | ||||
|         """ | ||||
|         Return a list of requested Part objects | ||||
|         """ | ||||
|  | ||||
|         parts = [] | ||||
|  | ||||
|         params = self.request.query_params | ||||
|  | ||||
|         for key in ['part', 'part[]', 'parts', 'parts[]']: | ||||
|             if key in params: | ||||
|                 parts = params.getlist(key, []) | ||||
|                 break | ||||
|                  | ||||
|         valid_ids = [] | ||||
|  | ||||
|         for part in parts: | ||||
|             try: | ||||
|                 valid_ids.append(int(part)) | ||||
|             except (ValueError): | ||||
|                 pass | ||||
|  | ||||
|         # List of Part objects which match provided values | ||||
|         return Part.objects.filter(pk__in=valid_ids) | ||||
|  | ||||
|  | ||||
| class PartLabelList(LabelListView, PartLabelMixin): | ||||
|     """ | ||||
|     API endpoint for viewing list of PartLabel objects | ||||
|     """ | ||||
|  | ||||
|     queryset = PartLabel.objects.all() | ||||
|     serializer_class = PartLabelSerializer | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|  | ||||
|         queryset = super().filter_queryset(queryset) | ||||
|  | ||||
|         parts = self.get_parts() | ||||
|  | ||||
|         if len(parts) > 0: | ||||
|  | ||||
|             valid_label_ids = set() | ||||
|  | ||||
|             for label in queryset.all(): | ||||
|  | ||||
|                 matches = True | ||||
|  | ||||
|                 try: | ||||
|                     filters = InvenTree.helpers.validateFilterString(label.filters) | ||||
|                 except ValidationError: | ||||
|                     continue | ||||
|  | ||||
|                 for part in parts: | ||||
|  | ||||
|                     part_query = Part.objects.filter(pk=part.pk) | ||||
|  | ||||
|                     try: | ||||
|                         if not part_query.filter(**filters).exists(): | ||||
|                             matches = False | ||||
|                             break | ||||
|                     except FieldError: | ||||
|                         matches = False | ||||
|                         break | ||||
|  | ||||
|                 if matches: | ||||
|                     valid_label_ids.add(label.pk) | ||||
|  | ||||
|             # Reduce queryset to only valid matches | ||||
|             queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids]) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|  | ||||
| class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView): | ||||
|     """ | ||||
|     API endpoint for a single PartLabel object | ||||
|     """ | ||||
|  | ||||
|     queryset = PartLabel.objects.all() | ||||
|     serializer_class = PartLabelSerializer | ||||
|  | ||||
|  | ||||
| class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin): | ||||
|     """ | ||||
|     API endpoint for printing a PartLabel object | ||||
|     """ | ||||
|  | ||||
|     queryset = PartLabel.objects.all() | ||||
|     serializer_class = PartLabelSerializer | ||||
|  | ||||
|     def get(self, request, *args, **kwargs): | ||||
|         """ | ||||
|         Check if valid part(s) have been provided | ||||
|         """ | ||||
|  | ||||
|         parts = self.get_parts() | ||||
|  | ||||
|         return self.print(request, parts) | ||||
|  | ||||
|  | ||||
| label_api_urls = [ | ||||
|  | ||||
|     # Stock item labels | ||||
| @@ -401,4 +509,16 @@ label_api_urls = [ | ||||
|         # List view | ||||
|         url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'), | ||||
|     ])), | ||||
|  | ||||
|     # Part labels | ||||
|     url(r'^part/', include([ | ||||
|         # Detail views | ||||
|         url(r'^(?P<pk>\d+)/', include([ | ||||
|             url(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), | ||||
|             url(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'), | ||||
|         ])), | ||||
|  | ||||
|         # List view | ||||
|         url(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'), | ||||
|     ])), | ||||
| ] | ||||
|   | ||||
| @@ -37,6 +37,7 @@ class LabelConfig(AppConfig): | ||||
|         if canAppAccessDatabase(): | ||||
|             self.create_stock_item_labels() | ||||
|             self.create_stock_location_labels() | ||||
|             self.create_part_labels() | ||||
|  | ||||
|     def create_stock_item_labels(self): | ||||
|         """ | ||||
| @@ -65,7 +66,7 @@ class LabelConfig(AppConfig): | ||||
|         ) | ||||
|  | ||||
|         if not os.path.exists(dst_dir): | ||||
|             logger.info(f"Creating missing directory: '{dst_dir}'") | ||||
|             logger.info(f"Creating required directory: '{dst_dir}'") | ||||
|             os.makedirs(dst_dir, exist_ok=True) | ||||
|  | ||||
|         labels = [ | ||||
| @@ -109,7 +110,6 @@ 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 | ||||
| @@ -125,8 +125,6 @@ class LabelConfig(AppConfig): | ||||
|                 width=label['width'], | ||||
|                 height=label['height'], | ||||
|             ) | ||||
|             except: | ||||
|                 pass | ||||
|  | ||||
|     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,7 +204,6 @@ 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 | ||||
| @@ -222,5 +219,88 @@ class LabelConfig(AppConfig): | ||||
|                 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: | ||||
|                 pass | ||||
|             # Database might not yet be ready | ||||
|             return | ||||
|  | ||||
|         src_dir = os.path.join( | ||||
|             os.path.dirname(os.path.realpath(__file__)), | ||||
|             'templates', | ||||
|             'label', | ||||
|             'part', | ||||
|         ) | ||||
|  | ||||
|         dst_dir = os.path.join( | ||||
|             settings.MEDIA_ROOT, | ||||
|             'label', | ||||
|             'inventree', | ||||
|             'part', | ||||
|         ) | ||||
|  | ||||
|         if not os.path.exists(dst_dir): | ||||
|             logger.info(f"Creating required directory: '{dst_dir}'") | ||||
|             os.makedirs(dst_dir, exist_ok=True) | ||||
|  | ||||
|         labels = [ | ||||
|             { | ||||
|                 'file': 'part_label.html', | ||||
|                 'name': 'Part Label', | ||||
|                 'description': 'Simple part label', | ||||
|                 'width': 70, | ||||
|                 'height': 24, | ||||
|             }, | ||||
|         ] | ||||
|  | ||||
|         for label in labels: | ||||
|  | ||||
|             filename = os.path.join( | ||||
|                 'label', | ||||
|                 'inventree', | ||||
|                 'part', | ||||
|                 label['file'] | ||||
|             ) | ||||
|  | ||||
|             src_file = os.path.join(src_dir, label['file']) | ||||
|             dst_file = os.path.join(settings.MEDIA_ROOT, filename) | ||||
|  | ||||
|             to_copy = False | ||||
|  | ||||
|             if os.path.exists(dst_file): | ||||
|                 # File already exists - let's see if it is the "same" | ||||
|  | ||||
|                 if not hashFile(dst_file) == hashFile(src_file): | ||||
|                     logger.info(f"Hash differs for '{filename}'") | ||||
|                     to_copy = True | ||||
|  | ||||
|             else: | ||||
|                 logger.info(f"Label template '{filename}' is not present") | ||||
|                 to_copy = True | ||||
|  | ||||
|             if to_copy: | ||||
|                 logger.info(f"Copying label template '{dst_file}'") | ||||
|                 shutil.copyfile(src_file, dst_file) | ||||
|  | ||||
|             # Check if a label matching the template already exists | ||||
|             if PartLabel.objects.filter(label=filename).exists(): | ||||
|                 continue | ||||
|  | ||||
|             logger.info(f"Creating entry for PartLabel '{label['name']}'") | ||||
|  | ||||
|             PartLabel.objects.create( | ||||
|                 name=label['name'], | ||||
|                 description=label['description'], | ||||
|                 label=filename, | ||||
|                 filters='', | ||||
|                 enabled=True, | ||||
|                 width=label['width'], | ||||
|                 height=label['height'], | ||||
|             ) | ||||
|   | ||||
							
								
								
									
										37
									
								
								InvenTree/label/migrations/0008_auto_20210708_2106.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								InvenTree/label/migrations/0008_auto_20210708_2106.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| # Generated by Django 3.2.4 on 2021-07-08 11:06 | ||||
|  | ||||
| import django.core.validators | ||||
| from django.db import migrations, models | ||||
| import label.models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('label', '0007_auto_20210513_1327'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='PartLabel', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')), | ||||
|                 ('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')), | ||||
|                 ('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')), | ||||
|                 ('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')), | ||||
|                 ('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')), | ||||
|                 ('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')), | ||||
|                 ('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')), | ||||
|                 ('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'abstract': False, | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='stockitemlabel', | ||||
|             name='filters', | ||||
|             field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -25,6 +25,8 @@ from InvenTree.helpers import validateFilterString, normalize | ||||
|  | ||||
| import common.models | ||||
| import stock.models | ||||
| import part.models | ||||
|  | ||||
|  | ||||
| try: | ||||
|     from django_weasyprint import WeasyTemplateResponseMixin | ||||
| @@ -59,6 +61,13 @@ def validate_stock_location_filters(filters): | ||||
|     return filters | ||||
|  | ||||
|  | ||||
| def validate_part_filters(filters): | ||||
|  | ||||
|     filters = validateFilterString(filters, model=part.models.Part) | ||||
|  | ||||
|     return filters | ||||
|  | ||||
|  | ||||
| class WeasyprintLabelMixin(WeasyTemplateResponseMixin): | ||||
|     """ | ||||
|     Class for rendering a label to a PDF | ||||
| @@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate): | ||||
|  | ||||
|     filters = models.CharField( | ||||
|         blank=True, max_length=250, | ||||
|         help_text=_('Query filters (comma-separated list of key=value pairs'), | ||||
|         help_text=_('Query filters (comma-separated list of key=value pairs),'), | ||||
|         verbose_name=_('Filters'), | ||||
|         validators=[ | ||||
|             validate_stock_item_filters] | ||||
|             validate_stock_item_filters | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     def matches_stock_item(self, item): | ||||
| @@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate): | ||||
|             'location': location, | ||||
|             'qr_data': location.format_barcode(brief=True), | ||||
|         } | ||||
|  | ||||
|  | ||||
| class PartLabel(LabelTemplate): | ||||
|     """ | ||||
|     Template for printing Part labels | ||||
|     """ | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_api_url(): | ||||
|         return reverse('api-part-label-list') | ||||
|  | ||||
|     SUBDIR = 'part' | ||||
|  | ||||
|     filters = models.CharField( | ||||
|         blank=True, max_length=250, | ||||
|         help_text=_('Part query filters (comma-separated value of key=value pairs)'), | ||||
|         verbose_name=_('Filters'), | ||||
|         validators=[ | ||||
|             validate_part_filters | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     def matches_part(self, part): | ||||
|         """ | ||||
|         Test if this label template matches a given Part object | ||||
|         """ | ||||
|  | ||||
|         try: | ||||
|             filters = validateFilterString(self.filters) | ||||
|             parts = part.models.Part.objects.filter(**filters) | ||||
|         except (ValidationError, FieldError): | ||||
|             return False | ||||
|  | ||||
|         parts = parts.filter(pk=part.pk) | ||||
|  | ||||
|         return parts.exists() | ||||
|  | ||||
|     def get_context_data(self, request): | ||||
|         """ | ||||
|         Generate context data for each provided Part object | ||||
|         """ | ||||
|  | ||||
|         part = self.object_to_print | ||||
|  | ||||
|         return { | ||||
|             'part': part, | ||||
|             'category': part.category, | ||||
|             'name': part.name, | ||||
|             'description': part.description, | ||||
|             'IPN': part.IPN, | ||||
|             'revision': part.revision, | ||||
|             'qr_data': part.format_barcode(brief=True), | ||||
|             'qr_url': part.format_barcode(url=True, request=request), | ||||
|         } | ||||
|   | ||||
| @@ -4,7 +4,7 @@ from __future__ import unicode_literals | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField | ||||
|  | ||||
| from .models import StockItemLabel, StockLocationLabel | ||||
| from .models import StockItemLabel, StockLocationLabel, PartLabel | ||||
|  | ||||
|  | ||||
| class StockItemLabelSerializer(InvenTreeModelSerializer): | ||||
| @@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer): | ||||
|             'filters', | ||||
|             'enabled', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartLabelSerializer(InvenTreeModelSerializer): | ||||
|     """ | ||||
|     Serializes a PartLabel object | ||||
|     """ | ||||
|  | ||||
|     label = InvenTreeAttachmentSerializerField(required=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = PartLabel | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'name', | ||||
|             'description', | ||||
|             'label', | ||||
|             'filters', | ||||
|             'enabled', | ||||
|         ] | ||||
|   | ||||
							
								
								
									
										33
									
								
								InvenTree/label/templates/label/part/part_label.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								InvenTree/label/templates/label/part/part_label.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| {% extends "label/label_base.html" %} | ||||
|  | ||||
| {% load barcode %} | ||||
|  | ||||
| {% block style %} | ||||
|  | ||||
| .qr { | ||||
|     position: fixed; | ||||
|     left: 0mm; | ||||
|     top: 0mm; | ||||
|     height: {{ height }}mm; | ||||
|     width: {{ height }}mm; | ||||
| } | ||||
|  | ||||
| .part { | ||||
|     font-family: Arial, Helvetica, sans-serif; | ||||
|     display: inline; | ||||
|     position: absolute; | ||||
|     left: {{ height }}mm; | ||||
|     top: 2mm; | ||||
| } | ||||
|  | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <img class='qr' src='{% qrcode qr_data %}'> | ||||
|  | ||||
| <div class='part'> | ||||
|     {{ part.full_name }} | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -268,6 +268,10 @@ | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $('#print-label').click(function() { | ||||
|         printPartLabels([{{ part.pk }}]); | ||||
|     }); | ||||
|  | ||||
|     $("#part-count").click(function() { | ||||
|         launchModalForm("/stock/adjust/", { | ||||
|             data: { | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -87,6 +87,7 @@ class RuleSet(models.Model): | ||||
|             'company_supplierpart', | ||||
|             'company_manufacturerpart', | ||||
|             'company_manufacturerpartparameter', | ||||
|             'label_partlabel', | ||||
|         ], | ||||
|         'stock_location': [ | ||||
|             'stock_stocklocation', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user