diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 7e3ed2c7a6..70760624c6 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -204,7 +204,6 @@ INSTALLED_APPS = [ 'crispy_forms', # Improved form rendering 'import_export', # Import / export tables to file 'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files - 'qr_code', # Generate QR codes 'mptt', # Modified Preorder Tree Traversal 'markdownx', # Markdown editing 'markdownify', # Markdown template rendering @@ -250,6 +249,7 @@ TEMPLATES = [ os.path.join(BASE_DIR, 'templates'), # Allow templates in the reporting directory to be accessed os.path.join(MEDIA_ROOT, 'report'), + os.path.join(MEDIA_ROOT, 'label'), ], 'APP_DIRS': True, 'OPTIONS': { @@ -407,8 +407,6 @@ CACHES = { } } -QR_CODE_CACHE_ALIAS = 'qr-code' - # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index a0053e5f53..34c101e86d 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -125,6 +125,7 @@ .qr-container { width: 100%; align-content: center; + object-fit: fill; } .navbar-brand { diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 1df5e83d5d..c5b439c0be 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -9,7 +9,6 @@ from django.conf.urls import url, include from django.urls import path from django.contrib import admin from django.contrib.auth import views as auth_views -from qr_code import urls as qr_code_urls from company.urls import company_urls from company.urls import supplier_part_urls @@ -21,7 +20,7 @@ from stock.urls import stock_urls from build.urls import build_urls from order.urls import order_urls -from barcode.api import barcode_api_urls +from barcodes.api import barcode_api_urls from common.api import common_api_urls from part.api import part_api_urls, bom_api_urls from company.api import company_api_urls @@ -141,8 +140,6 @@ urlpatterns = [ url(r'^admin/shell/', include('django_admin_shell.urls')), url(r'^admin/', admin.site.urls, name='inventree-admin'), - url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')), - url(r'^index/', IndexView.as_view(), name='index'), url(r'^search/', SearchView.as_view(), name='search'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), diff --git a/InvenTree/barcode/__init__.py b/InvenTree/barcodes/__init__.py similarity index 100% rename from InvenTree/barcode/__init__.py rename to InvenTree/barcodes/__init__.py diff --git a/InvenTree/barcode/api.py b/InvenTree/barcodes/api.py similarity index 99% rename from InvenTree/barcode/api.py rename to InvenTree/barcodes/api.py index f8a2c37329..d727f5a778 100644 --- a/InvenTree/barcode/api.py +++ b/InvenTree/barcodes/api.py @@ -12,7 +12,7 @@ from rest_framework.views import APIView from stock.models import StockItem from stock.serializers import StockItemSerializer -from barcode.barcode import load_barcode_plugins, hash_barcode +from barcodes.barcode import load_barcode_plugins, hash_barcode class BarcodeScan(APIView): diff --git a/InvenTree/barcode/barcode.py b/InvenTree/barcodes/barcode.py similarity index 98% rename from InvenTree/barcode/barcode.py rename to InvenTree/barcodes/barcode.py index 004182e0be..412065bf75 100644 --- a/InvenTree/barcode/barcode.py +++ b/InvenTree/barcodes/barcode.py @@ -5,7 +5,7 @@ import hashlib import logging from InvenTree import plugins as InvenTreePlugins -from barcode import plugins as BarcodePlugins +from barcodes import plugins as BarcodePlugins from stock.models import StockItem from stock.serializers import StockItemSerializer, LocationSerializer diff --git a/InvenTree/barcode/plugins/digikey_barcode.py b/InvenTree/barcodes/plugins/digikey_barcode.py similarity index 85% rename from InvenTree/barcode/plugins/digikey_barcode.py rename to InvenTree/barcodes/plugins/digikey_barcode.py index 1a0825e03c..656cdfa6f4 100644 --- a/InvenTree/barcode/plugins/digikey_barcode.py +++ b/InvenTree/barcodes/plugins/digikey_barcode.py @@ -4,7 +4,7 @@ DigiKey barcode decoding """ -from barcode.barcode import BarcodePlugin +from barcodes.barcode import BarcodePlugin class DigikeyBarcodePlugin(BarcodePlugin): diff --git a/InvenTree/barcode/plugins/inventree_barcode.py b/InvenTree/barcodes/plugins/inventree_barcode.py similarity index 98% rename from InvenTree/barcode/plugins/inventree_barcode.py rename to InvenTree/barcodes/plugins/inventree_barcode.py index 93fb58cbc0..842f9029aa 100644 --- a/InvenTree/barcode/plugins/inventree_barcode.py +++ b/InvenTree/barcodes/plugins/inventree_barcode.py @@ -13,7 +13,7 @@ references model objects actually exist in the database. import json -from barcode.barcode import BarcodePlugin +from barcodes.barcode import BarcodePlugin from stock.models import StockItem, StockLocation from part.models import Part diff --git a/InvenTree/barcode/tests.py b/InvenTree/barcodes/tests.py similarity index 100% rename from InvenTree/barcode/tests.py rename to InvenTree/barcodes/tests.py diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index b2bfe9164f..a4800cd5d9 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import sys - from django.utils.translation import ugettext as _ from django.conf.urls import url, include from django.core.exceptions import ValidationError, FieldError +from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend @@ -13,6 +12,7 @@ from rest_framework import generics, filters from rest_framework.response import Response import InvenTree.helpers +import common.models from stock.models import StockItem, StockLocation @@ -40,6 +40,74 @@ class LabelListView(generics.ListAPIView): ] +class LabelPrintMixin: + """ + Mixin for printing labels + """ + + def print(self, request, items_to_print): + """ + Print this label template against a number of pre-validated items + """ + + if len(items_to_print) == 0: + # No valid items provided, return an error message + data = { + 'error': _('No valid objects provided to template'), + } + + return Response(data, status=400) + + outputs = [] + + # In debug mode, generate single HTML output, rather than PDF + debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') + + # Merge one or more PDF files into a single download + for item in items_to_print: + label = self.get_object() + label.object_to_print = item + + if debug_mode: + outputs.append(label.render_as_string(request)) + else: + outputs.append(label.render(request)) + + if debug_mode: + """ + Contatenate all rendered templates into a single HTML string, + and return the string as a HTML response. + """ + + html = "\n".join(outputs) + + return HttpResponse(html) + else: + """ + Concatenate all rendered pages into a single PDF object, + and return the resulting document! + """ + + pages = [] + + if len(outputs) > 1: + # If more than one output is generated, merge them into a single file + for output in outputs: + doc = output.get_document() + for page in doc.pages: + pages.append(page) + + pdf = outputs[0].get_document().copy(pages).write_pdf() + else: + pdf = outputs[0].get_document().write_pdf() + + return InvenTree.helpers.DownloadFile( + pdf, + 'inventree_label.pdf', + content_type='application/pdf' + ) + + class StockItemLabelMixin: """ Mixin for extracting stock items from query params @@ -158,7 +226,7 @@ class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = StockItemLabelSerializer -class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin): +class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin): """ API endpoint for printing a StockItemLabel object """ @@ -173,34 +241,7 @@ class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin): items = self.get_items() - if len(items) == 0: - # No valid items provided, return an error message - data = { - 'error': _('Must provide valid StockItem(s)'), - } - - return Response(data, status=400) - - label = self.get_object() - - try: - pdf = label.render(items) - except: - - e = sys.exc_info()[1] - - data = { - 'error': _('Error during label rendering'), - 'message': str(e), - } - - return Response(data, status=400) - - return InvenTree.helpers.DownloadFile( - pdf.getbuffer(), - 'stock_item_label.pdf', - content_type='application/pdf' - ) + return self.print(request, items) class StockLocationLabelMixin: @@ -320,7 +361,7 @@ class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = StockLocationLabelSerializer -class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin): +class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin): """ API endpoint for printing a StockLocationLabel object """ @@ -332,35 +373,7 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin) locations = self.get_locations() - if len(locations) == 0: - # No valid locations provided - return an error message - - return Response( - { - 'error': _('Must provide valid StockLocation(s)'), - }, - status=400, - ) - - label = self.get_object() - - try: - pdf = label.render(locations) - except: - e = sys.exc_info()[1] - - data = { - 'error': _('Error during label rendering'), - 'message': str(e), - } - - return Response(data, status=400) - - return InvenTree.helpers.DownloadFile( - pdf.getbuffer(), - 'stock_location_label.pdf', - content_type='application/pdf' - ) + return self.print(request, locations) label_api_urls = [ diff --git a/InvenTree/label/apps.py b/InvenTree/label/apps.py index 321047a551..9f2d3ea9c4 100644 --- a/InvenTree/label/apps.py +++ b/InvenTree/label/apps.py @@ -1,6 +1,7 @@ import os import shutil import logging +import hashlib from django.apps import AppConfig from django.conf import settings @@ -9,6 +10,20 @@ from django.conf import settings logger = logging.getLogger(__name__) +def hashFile(filename): + """ + Calculate the MD5 hash of a file + """ + + md5 = hashlib.md5() + + with open(filename, 'rb') as f: + data = f.read() + md5.update(data) + + return md5.hexdigest() + + class LabelConfig(AppConfig): name = 'label' @@ -35,6 +50,7 @@ class LabelConfig(AppConfig): src_dir = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'templates', + 'label', 'stockitem', ) @@ -54,6 +70,8 @@ class LabelConfig(AppConfig): 'file': 'qr.html', 'name': 'QR Code', 'description': 'Simple QR code label', + 'width': 24, + 'height': 24, }, ] @@ -70,7 +88,21 @@ class LabelConfig(AppConfig): src_file = os.path.join(src_dir, label['file']) dst_file = os.path.join(settings.MEDIA_ROOT, filename) - if not os.path.exists(dst_file): + to_copy = False + + if os.path.exists(dst_file): + # File already exists - let's see if it is the "same", + # or if we need to overwrite it with a newer copy! + + 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) @@ -86,7 +118,9 @@ class LabelConfig(AppConfig): description=label['description'], label=filename, filters='', - enabled=True + enabled=True, + width=label['width'], + height=label['height'], ) except: pass @@ -106,6 +140,7 @@ class LabelConfig(AppConfig): src_dir = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'templates', + 'label', 'stocklocation', ) @@ -125,11 +160,15 @@ class LabelConfig(AppConfig): 'file': 'qr.html', 'name': 'QR Code', 'description': 'Simple QR code label', + 'width': 24, + 'height': 24, }, { 'file': 'qr_and_text.html', 'name': 'QR and text', 'description': 'Label with QR code and name of location', + 'width': 50, + 'height': 24, } ] @@ -146,7 +185,21 @@ class LabelConfig(AppConfig): src_file = os.path.join(src_dir, label['file']) dst_file = os.path.join(settings.MEDIA_ROOT, filename) - if not os.path.exists(dst_file): + to_copy = False + + if os.path.exists(dst_file): + # File already exists - let's see if it is the "same", + # or if we need to overwrite it with a newer copy! + + 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) @@ -162,7 +215,9 @@ class LabelConfig(AppConfig): description=label['description'], label=filename, filters='', - enabled=True + enabled=True, + width=label['width'], + height=label['height'], ) except: pass diff --git a/InvenTree/label/migrations/0006_auto_20210222_1535.py b/InvenTree/label/migrations/0006_auto_20210222_1535.py new file mode 100644 index 0000000000..ea3441b64f --- /dev/null +++ b/InvenTree/label/migrations/0006_auto_20210222_1535.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.7 on 2021-02-22 04:35 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('label', '0005_auto_20210113_2302'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemlabel', + name='height', + field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'), + ), + migrations.AddField( + model_name='stockitemlabel', + name='width', + field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'), + ), + migrations.AddField( + model_name='stocklocationlabel', + name='height', + field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'), + ), + migrations.AddField( + model_name='stocklocationlabel', + name='width', + field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'), + ), + ] diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 9a98810d28..0c6a108f31 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -5,21 +5,35 @@ Label printing models # -*- coding: utf-8 -*- from __future__ import unicode_literals +import sys import os -import io - -from blabel import LabelWriter +import logging +import datetime +from django.conf import settings from django.db import models -from django.core.validators import FileExtensionValidator +from django.core.validators import FileExtensionValidator, MinValueValidator from django.core.exceptions import ValidationError, FieldError +from django.template.loader import render_to_string + from django.utils.translation import gettext_lazy as _ from InvenTree.helpers import validateFilterString, normalize +import common.models import stock.models +try: + from django_weasyprint import WeasyTemplateResponseMixin +except OSError as err: + print("OSError: {e}".format(e=err)) + print("You may require some further system packages to be installed.") + sys.exit(1) + + +logger = logging.getLogger(__name__) + def rename_label(instance, filename): """ Place the label file into the correct subdirectory """ @@ -43,6 +57,21 @@ def validate_stock_location_filters(filters): return filters +class WeasyprintLabelMixin(WeasyTemplateResponseMixin): + """ + Class for rendering a label to a PDF + """ + + pdf_filename = 'label.pdf' + pdf_attachment = True + + def __init__(self, request, template, **kwargs): + + self.request = request + self.template_name = template + self.pdf_filename = kwargs.get('filename', 'label.pdf') + + class LabelTemplate(models.Model): """ Base class for generic, filterable labels. @@ -53,6 +82,9 @@ class LabelTemplate(models.Model): # Each class of label files will be stored in a separate subdirectory SUBDIR = "label" + + # Object we will be printing against (will be filled out later) + object_to_print = None @property def template(self): @@ -92,38 +124,90 @@ class LabelTemplate(models.Model): help_text=_('Label template is enabled'), ) - def get_record_data(self, items): + width = models.FloatField( + default=50, + verbose_name=('Width [mm]'), + help_text=_('Label width, specified in mm'), + validators=[MinValueValidator(2)] + ) + + height = models.FloatField( + default=20, + verbose_name=_('Height [mm]'), + help_text=_('Label height, specified in mm'), + validators=[MinValueValidator(2)] + ) + + @property + def template_name(self): """ - Return a list of dict objects, one for each item. + Returns the file system path to the template file. + Required for passing the file to an external process """ - return [] + template = self.label.name + template = template.replace('/', os.path.sep) + template = template.replace('\\', os.path.sep) - def render_to_file(self, filename, items, **kwargs): + template = os.path.join(settings.MEDIA_ROOT, template) + + return template + + def get_context_data(self, request): """ - Render labels to a PDF file + Supply custom context data to the template for rendering. + + Note: Override this in any subclass """ - records = self.get_record_data(items) + return {} - writer = LabelWriter(self.template) - - writer.write_labels(records, filename) - - def render(self, items, **kwargs): + def context(self, request): """ - Render labels to an in-memory PDF object, and return it + Provides context data to the template. """ - records = self.get_record_data(items) + context = self.get_context_data(request) - writer = LabelWriter(self.template) + # Add "basic" context data which gets passed to every label + context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL') + context['date'] = datetime.datetime.now().date() + context['datetime'] = datetime.datetime.now() + context['request'] = request + context['user'] = request.user + context['width'] = self.width + context['height'] = self.height - buffer = io.BytesIO() + return context - writer.write_labels(records, buffer) + def render_as_string(self, request, **kwargs): + """ + Render the label to a HTML string - return buffer + Useful for debug mode (viewing generated code) + """ + + return render_to_string(self.template_name, self.context(request), request) + + def render(self, request, **kwargs): + """ + Render the label template to a PDF file + + Uses django-weasyprint plugin to render HTML template + """ + + wp = WeasyprintLabelMixin( + request, + self.template_name, + base_url=request.build_absolute_uri("/"), + presentational_hints=True, + **kwargs + ) + + return wp.render_to_response( + self.context(request), + **kwargs + ) class StockItemLabel(LabelTemplate): @@ -157,29 +241,24 @@ class StockItemLabel(LabelTemplate): return items.exists() - def get_record_data(self, items): + def get_context_data(self, request): """ Generate context data for each provided StockItem """ - records = [] - - for item in items: - # Add some basic information - records.append({ - 'item': item, - 'part': item.part, - 'name': item.part.name, - 'ipn': item.part.IPN, - 'quantity': normalize(item.quantity), - 'serial': item.serial, - 'uid': item.uid, - 'pk': item.pk, - 'qr_data': item.format_barcode(brief=True), - 'tests': item.testResultMap() - }) + stock_item = self.object_to_print - return records + return { + 'item': stock_item, + 'part': stock_item.part, + 'name': stock_item.part.full_name, + 'ipn': stock_item.part.IPN, + 'quantity': normalize(stock_item.quantity), + 'serial': stock_item.serial, + 'uid': stock_item.uid, + 'qr_data': stock_item.format_barcode(brief=True), + 'tests': stock_item.testResultMap() + } class StockLocationLabel(LabelTemplate): @@ -212,17 +291,14 @@ class StockLocationLabel(LabelTemplate): return locs.exists() - def get_record_data(self, locations): + def get_context_data(self, request): """ Generate context data for each provided StockLocation """ - records = [] - - for loc in locations: + location = self.object_to_print - records.append({ - 'location': loc, - }) - - return records + return { + 'location': location, + 'qr_data': location.format_barcode(brief=True), + } diff --git a/InvenTree/label/templates/label/label_base.html b/InvenTree/label/templates/label/label_base.html new file mode 100644 index 0000000000..2c564d1132 --- /dev/null +++ b/InvenTree/label/templates/label/label_base.html @@ -0,0 +1,28 @@ +{% load report %} +{% load barcode %} + +
+ + + + + {% block content %} + + {% endblock %} + diff --git a/InvenTree/label/templates/label/stockitem/qr.html b/InvenTree/label/templates/label/stockitem/qr.html new file mode 100644 index 0000000000..8f489f81b8 --- /dev/null +++ b/InvenTree/label/templates/label/stockitem/qr.html @@ -0,0 +1,20 @@ +{% extends "label/label_base.html" %} + +{% load barcode %} + +{% block style %} + +.qr { + position: fixed; + left: 0mm; + top: 0mm; + height: {{ height }}mm; + width: {{ height }}mm; +} + +{% endblock %} + +{% block content %} + + +{% endblock %} diff --git a/InvenTree/label/templates/label/stocklocation/qr.html b/InvenTree/label/templates/label/stocklocation/qr.html new file mode 100644 index 0000000000..8f489f81b8 --- /dev/null +++ b/InvenTree/label/templates/label/stocklocation/qr.html @@ -0,0 +1,20 @@ +{% extends "label/label_base.html" %} + +{% load barcode %} + +{% block style %} + +.qr { + position: fixed; + left: 0mm; + top: 0mm; + height: {{ height }}mm; + width: {{ height }}mm; +} + +{% endblock %} + +{% block content %} + + +{% endblock %} diff --git a/InvenTree/label/templates/label/stocklocation/qr_and_text.html b/InvenTree/label/templates/label/stocklocation/qr_and_text.html new file mode 100644 index 0000000000..2ff54dc817 --- /dev/null +++ b/InvenTree/label/templates/label/stocklocation/qr_and_text.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; +} + +.loc { + font-family: Arial, Helvetica, sans-serif; + display: inline; + position: absolute; + left: {{ height }}mm; + top: 2mm; +} + +{% endblock %} + +{% block content %} + + + +