From 5046d23a4584836aa09e3aa313359b1501399c1d Mon Sep 17 00:00:00 2001 From: LHBL2003 Date: Tue, 13 Aug 2024 11:46:08 +0200 Subject: [PATCH 01/14] First functional version without documentation. --- netbox_qrcode/__init__.py | 32 ++- netbox_qrcode/template_content.py | 190 ++++++++++-------- netbox_qrcode/template_content_functions.py | 149 ++++++++++++++ .../templates/netbox_qrcode/qrcode3.html | 144 ++++++++++++- .../netbox_qrcode/qrcode3_sub_qrcode.html | 13 ++ netbox_qrcode/utilities.py | 113 ++--------- 6 files changed, 450 insertions(+), 191 deletions(-) create mode 100644 netbox_qrcode/template_content_functions.py create mode 100644 netbox_qrcode/templates/netbox_qrcode/qrcode3_sub_qrcode.html diff --git a/netbox_qrcode/__init__.py b/netbox_qrcode/__init__.py index 6d16a96..3975ba4 100644 --- a/netbox_qrcode/__init__.py +++ b/netbox_qrcode/__init__.py @@ -11,15 +11,35 @@ class QRCodeConfig(PluginConfig): author_email = 'mgk.kolek@gmail.com' required_settings = [] default_settings = { + 'title': '', 'with_text': True, + 'with_qr': True, 'text_fields': ['name', 'serial'], - 'font': 'TahomaBold', + 'font': '\'Trebuchet MS\', sans-serif', + 'font_size': '3mm', 'custom_text': None, 'text_location': 'right', - 'qr_version': 1, + + #'url_template': 'Device-{{ obj.name }}', + + # These parameters are used to create the QR code image file. + 'qr_version': 1, # The higher the value, the more boxes you get. 'qr_error_correction': 0, - 'qr_box_size': 6, - 'qr_border': 4, + 'qr_box_size': 4, # The smaller the number of pixels, the blurrier the QR code will be if the label dimensions are too large, but the quicker the QR code will be ready. + 'qr_border': 0, + + # Parameters for the label (Horizontal) + 'label_qr_width': '12mm', + 'label_qr_height': '12mm', + 'label_qr_text_distance': '1mm', + 'label_width': '56mm', + 'label_height': '32mm', + 'label_edge_top': '0mm', + 'label_edge_left': '1.5mm', + 'label_edge_right': '1.5mm', + 'label_edge_bottom': '0mm', + + # Module-dependent configuration 'device': { 'text_fields': ['name', 'serial'] }, @@ -46,7 +66,9 @@ class QRCodeConfig(PluginConfig): }, 'powerpanel': { 'text_fields': ['name'] - } + }, + + 'logo': '' } config = QRCodeConfig # noqa E305 diff --git a/netbox_qrcode/template_content.py b/netbox_qrcode/template_content.py index d4b44ba..0d6073b 100644 --- a/netbox_qrcode/template_content.py +++ b/netbox_qrcode/template_content.py @@ -1,124 +1,152 @@ from packaging import version - from django.conf import settings from django.core.exceptions import ObjectDoesNotExist -from django.template import engines - from netbox.plugins import PluginTemplateExtension +from .template_content_functions import create_text, create_url, config_for_modul, create_QRCode -from .utilities import get_img_b64, get_qr, get_qr_text, get_concat - +# ****************************************************************************************** +# Contains the main functionalities of the plugin and thus creates the content for the +# individual modules, e.g: Device, Rack etc. +# ****************************************************************************************** +################################## +# Class for creating the plugin content class QRCode(PluginTemplateExtension): - def x_page(self): - config = self.context['config'] - obj = self.context['object'] - request = self.context['request'] - url = request.build_absolute_uri(obj.get_absolute_url()) - # get object settings - obj_cfg = config.get(self.model.replace('dcim.', '')) - if obj_cfg is None: - return '' - # and ovverride default - config.update(obj_cfg) - - qr_args = {} - for k, v in config.items(): - if k.startswith('qr_'): - qr_args[k.replace('qr_', '')] = v - - qr_img = get_qr(url, **qr_args) - if config.get('with_text'): - if config.get('text_template'): - django_engine = engines["django"] - template = django_engine.from_string(config.get('text_template')) - text = template.render({'obj': obj}) - else: - text = [] - for text_field in config.get('text_fields', []): - cfn = None - if '.' in text_field: - try: - text_field, cfn = text_field.split('.') - except ValueError: - cfn = None - if getattr(obj, text_field, None): - if cfn: - try: - if getattr(obj, text_field).get(cfn): - text.append('{}'.format(getattr(obj, text_field).get(cfn))) - except AttributeError: - # fix for nb3.3: trying to get cable termination and device in same way as custom field - if type(getattr(obj, text_field)) is list: - first_element = next(iter(getattr(obj, text_field)), None) - if first_element and getattr(first_element, cfn, None): - text.append('{}'.format(getattr(first_element, cfn))) - else: - text.append('{}'.format(getattr(obj, text_field))) - custom_text = config.get('custom_text') - if custom_text: - text.append(custom_text) - text = '\n'.join(text) - text_img = get_qr_text(qr_img.size, text, config.get('font'), config.get('font_size', 0)) - qr_with_text = get_concat(qr_img, text_img, config.get('text_location', 'right')) - - img = get_img_b64(qr_with_text) - else: - img = get_img_b64(qr_img) + ################################## + # Creates a plug-in view for a label. + # -------------------------------- + # Parameter: + # labelDesignNo: Which label design should be loaded. + def Create_SubPluginContent(self, labelDesignNo): + + thisSelf = self + + obj = self.context['object'] # An object of the type Device, Rack etc. + + # Config suitable for the module + config = config_for_modul(thisSelf, labelDesignNo) + + # Abort if no config data. + if config is None: + return '' + + # Get URL for QR code + url = create_url(thisSelf, config, obj) + + # Create a QR code + qrCode = create_QRCode(url, config) + + # Create the text for the label if required. + text = create_text(config, obj, qrCode) + + # Create plugin using template try: if version.parse(settings.VERSION).major >= 3: - return self.render( - 'netbox_qrcode/qrcode3.html', extra_context={'image': img} + + render = self.render( + 'netbox_qrcode/qrcode3.html', extra_context={ + 'title': config.get('title'), + 'labelDesignNo': labelDesignNo, + 'qrCode': qrCode, + 'with_text': config.get('with_text'), + 'text': text, + 'text_location': config.get('text_location'), + 'font': config.get('font'), + 'font_size': config.get('font_size'), + 'with_qr': config.get('with_qr'), + 'label_qr_width': config.get('label_qr_width'), + 'label_qr_height': config.get('label_qr_height'), + 'label_qr_text_distance': config.get('label_qr_text_distance'), + 'label_width': config.get('label_width'), + 'label_height': config.get('label_height'), + 'label_edge_top': config.get('label_edge_top'), + 'label_edge_left': config.get('label_edge_left'), + 'label_edge_right': config.get('label_edge_right'), + 'label_edge_bottom': config.get('label_edge_bottom') + } ) + + return render else: + # Versions 1 and 2 are no longer supported. return self.render( - 'netbox_qrcode/qrcode.html', extra_context={'image': img} + 'netbox_qrcode/qrcode.html', extra_context={'image': qrCode} ) except ObjectDoesNotExist: return '' + ################################## + # Create plugin content + # - First, a plugin view is created for the first label. + # - If there are further configuration entries for the object/model (e.g. device, rack etc.), + # further label views are also created as additional plugin views. + def Create_PluginContent(self): + + # First Plugin Content + pluginContent = QRCode.Create_SubPluginContent(self, 1) + + # Check whether there is another configuration for the object, e.g. device, rack, etc. + # Support up to 10 additional label configurations (objectName_2 to ..._10) per object (e.g. device, rack, etc.). + + config = self.context['config'] # Django configuration + for i in range(2, 11): + + configName = self.model.replace('dcim.', '') + '_' + str(i) + obj_cfg = config.get(configName) # Load configuration for additional label if possible. + + if(obj_cfg): + pluginContent += QRCode.Create_SubPluginContent(self, i) # Add another plugin view + else: + break + + return pluginContent + +################################## +# The following section serves to integrate the plugin into Netbox Core. + +# Class for creating a QR code for the model: Device class DeviceQRCode(QRCode): - model = 'dcim.device' + model = 'dcim.device' # Info for Netbox in which model the plugin should be integrated. def right_page(self): - return self.x_page() - + return self.Create_PluginContent() +# Class for creating a QR code for the model: Rack class RackQRCode(QRCode): - model = 'dcim.rack' + model = 'dcim.rack' # Info for Netbox in which model the plugin should be integrated. def right_page(self): - return self.x_page() - + return self.Create_PluginContent() +# Class for creating a QR code for the model: Cable class CableQRCode(QRCode): - model = 'dcim.cable' + model = 'dcim.cable' # Info for Netbox in which model the plugin should be integrated. def left_page(self): - return self.x_page() - + return self.Create_PluginContent() +# Class for creating a QR code for the model: Location class LocationQRCode(QRCode): - model = 'dcim.location' + model = 'dcim.location' # Info for Netbox in which model the plugin should be integrated. def left_page(self): - return self.x_page() - + return self.Create_PluginContent() +# Class for creating a QR code for the model: Power Feed class PowerFeedQRCode(QRCode): - model = 'dcim.powerfeed' + model = 'dcim.powerfeed' # Info for Netbox in which model the plugin should be integrated. def right_page(self): - return self.x_page() - + return self.Create_PluginContent() +# Class for creating a QR code for the model: Power Panel class PowerPanelQRCode(QRCode): - model = 'dcim.powerpanel' + model = 'dcim.powerpanel' # Info for Netbox in which model the plugin should be integrated. def right_page(self): - return self.x_page() - + return self.Create_PluginContent() +# Connects Netbox Core with the plug-in classes template_extensions = [DeviceQRCode, RackQRCode, CableQRCode, LocationQRCode, PowerFeedQRCode, PowerPanelQRCode] diff --git a/netbox_qrcode/template_content_functions.py b/netbox_qrcode/template_content_functions.py new file mode 100644 index 0000000..1d17beb --- /dev/null +++ b/netbox_qrcode/template_content_functions.py @@ -0,0 +1,149 @@ +from .utilities import get_img_b64, get_qr +from django.template import engines + +# ****************************************************************************************** +# For better clarity, the sub-functions of template_content.py have been outsourced. +# ****************************************************************************************** + +################################## +# The configuration is taken and all fields that are module-specific (e.g. Device, Rack, etc.) are replaced. +# -------------------------------- +# Parameter: +# labelDesignNo: Which label design should be loaded. +# parentSelf: Self from Parrent Function +def config_for_modul(parentSelf, labelDesignNo): + + # Copy so that the Runtime data is not changed. + config = parentSelf.context['config'].copy() # From Netbox Config File + + # Create suffix to read the correct module configuration. + confModulsufix = str() # None if the first standard label + + if(labelDesignNo >= 2): + confModulsufix = '_' + str(labelDesignNo) + + # Collect the QR code plugin configuration for the specific object such as device, rack etc. + # and overwrite the default configuration fields. + obj_cfg = config.get(parentSelf.model.replace('dcim.', '') + confModulsufix) # get spezific object settings + + print(obj_cfg) + + if obj_cfg is None: + return '' # Abort if no config data. + + config.update(obj_cfg) # Ovverride default confiv Values + + return config + +################################## +# Create QR-Code +# -------------------------------- +# Parameter: +# text: Text for QR-Code +# config: From the Netbox configuration file +def create_QRCode(text, config): + + # Collect the configuration entries that begin with "qr_. + # These are required to generate the QR code. + qr_args = {} + for k, v in config.items(): + if k.startswith('qr_'): + qr_args[k.replace('qr_', '')] = v + + # Create a QR code + qrCode = get_qr(text, **qr_args) + return get_img_b64(qrCode) + + +################################## +# Create URL for QR code +# -------------------------------- +# Parameter: +# config: From the Netbox configuration file +# obj: Data from the model (e.g. device, rack, etc.) +# request: HTML Request Information +def create_url(parentSelf, config, obj): + + request = parentSelf.context['request'] # HTML Request Informations + + if config.get('url_template'): + # A user-defined design specification of the URL is provided in ninja2 format. + django_engine = engines["django"] + template = django_engine.from_string(config.get('url_template')) # Custom template for URL design. + return template.render({'obj': obj}) # Replace placeholder + else: + return request.build_absolute_uri(obj.get_absolute_url()) # URL to the requested page + +################################## +# Create text for label +# -------------------------------- +# Parameter: +# config: From the Netbox configuration file +# obj: Data from the model (e.g. device, rack, etc.) +# qrCode: QR-Code Image +def create_text(config, obj, qrCode): + + text = str() + + if config.get('with_text'): + if config.get('text_template'): + return get_text_template(config, obj, qrCode) # Create text content based on the Ninja2 template from the user + else: + return get_text_fields(config, obj) # Use the list of variables from the Config. + +################################## +# A user-defined design specification of the text is provided in ninja2 format. +# -------------------------------- +# Parameter: +# config: From the Netbox configuration file +# obj: Data from the model (e.g. device, rack, etc.) +# qrCode: QR-Code Image (To create a freely defined label with QR code.) +def get_text_template(config, obj, qrCode): + + django_engine = engines["django"] + template = django_engine.from_string(config.get('text_template')) # Get Custom Template + logo = config.get('logo') + return template.render({'obj': obj, + 'logo': logo, + 'qrCode': qrCode}) # Replace placeholder + +################################## +# Retrieves all values from the object (e.g. device, rack, etc.) +# depending on the configuration parameter that are to be displayed in list form and prepares them. +# -------------------------------- +# Parameter: +# config: From the Netbox configuration file +# obj: Data from the model (e.g. device, rack, etc.) +def get_text_fields(config, obj): + + text = [] + + for text_field in config.get('text_fields', []): + cfn = None + if '.' in text_field: + try: + text_field, cfn = text_field.split('.') + except ValueError: + cfn = None + if getattr(obj, text_field, None): + if cfn: + try: + if getattr(obj, text_field).get(cfn): + text.append('{}'.format(getattr(obj, text_field).get(cfn))) + except AttributeError: + # fix for nb3.3: trying to get cable termination and device in same way as custom field + if type(getattr(obj, text_field)) is list: + first_element = next(iter(getattr(obj, text_field)), None) + if first_element and getattr(first_element, cfn, None): + text.append('{}'.format(getattr(first_element, cfn))) + else: + text.append('{}'.format(getattr(obj, text_field))) + + # Append user-defined text to the end. + custom_text = config.get('custom_text') + + if custom_text: + text.append(custom_text) + + # Convert text list to string with line breaks. + return '
'.join(text) \ No newline at end of file diff --git a/netbox_qrcode/templates/netbox_qrcode/qrcode3.html b/netbox_qrcode/templates/netbox_qrcode/qrcode3.html index 88d4629..ec6cdae 100644 --- a/netbox_qrcode/templates/netbox_qrcode/qrcode3.html +++ b/netbox_qrcode/templates/netbox_qrcode/qrcode3.html @@ -1,19 +1,149 @@ + + +
- QR Code + OR-Code (H/W: {{label_height}} x {{label_width}}) {% if title %} - {{title}}{% endif %}
- +
+
+ + {# Only Text label #} + {% if with_text is True and with_qr is False %} + + + + +
+ {{ text|safe|escape }} +
+ {% endif %} + + {# Text and QR-Code #} + {% if with_text is True and with_qr is True %} + + {# Horizontal label #} + {% if text_location == "right" or text_location == "left" %} + + + + {% if text_location == "right" %} + + {% endif %} + + + + {% if text_location == "left" %} + + {% endif %} + + +
+ {% include "netbox_qrcode/qrcode3_sub_qrcode.html" %} + +
+ + {{ text|safe|escape }} + +
+
+ {% include "netbox_qrcode/qrcode3_sub_qrcode.html" %} +
+ {% endif %} + + {# Vertical label #} + {% if with_qr and text_location == "up" or text_location == "down" %} + + + {% if text_location == "down" %} + + + + {% endif %} + + + + + + {% if text_location == "up" %} + + + + {% endif %} + +
+ {% include "netbox_qrcode/qrcode3_sub_qrcode.html" %} +
+
+ + {{ text|safe|escape }} + +
+
+ {% include "netbox_qrcode/qrcode3_sub_qrcode.html" %} +
+ {% endif %} + {% endif %} + + {# Only QR-Code label #} + {% if with_text is False and with_qr is True %} +
+ {% include "netbox_qrcode/qrcode3_sub_qrcode.html" %} +
+ {% endif %} +
+
+ diff --git a/netbox_qrcode/templates/netbox_qrcode/qrcode3_sub_qrcode.html b/netbox_qrcode/templates/netbox_qrcode/qrcode3_sub_qrcode.html new file mode 100644 index 0000000..1c14039 --- /dev/null +++ b/netbox_qrcode/templates/netbox_qrcode/qrcode3_sub_qrcode.html @@ -0,0 +1,13 @@ +
+ +
\ No newline at end of file diff --git a/netbox_qrcode/utilities.py b/netbox_qrcode/utilities.py index c1e2f7c..e0cd376 100644 --- a/netbox_qrcode/utilities.py +++ b/netbox_qrcode/utilities.py @@ -1,18 +1,17 @@ import base64 import qrcode - from io import BytesIO -from PIL import Image, ImageFont, ImageDraw - -from pkg_resources import resource_stream - - -def get_qr_with_text(qr, descr): - dsi = get_qr_text(qr.size, descr) - resimg = get_concat(qr, dsi) - return get_img_b64(resimg) +# ****************************************************************************************** +# Includes useful tools to create the content. +# ****************************************************************************************** +################################## +# Creates a QR code as an image.: https://pypi.org/project/qrcode/3.0/ +# -------------------------------- +# Parameter: +# text: Text to be included in the QR code. +# **kwargs: List of parameters which properties the QR code should have. (e.g. version, box_size, error_correction, border etc.) def get_qr(text, **kwargs): qr = qrcode.QRCode(**kwargs) qr.add_data(text) @@ -21,94 +20,12 @@ def get_qr(text, **kwargs): img = img.get_image() return img - +################################## +# Converts an image to Base64 +# -------------------------------- +# Parameter: +# img: Image file def get_img_b64(img): stream = BytesIO() img.save(stream, format='png') - return str(base64.b64encode(stream.getvalue()), encoding='ascii') - - -def get_qr_text(max_size, text, font='TahomaBold', font_size=0): - tmpimg = Image.new('P', max_size, 'white') - - if font_size == 0: - text_too_large = True - font_size = 56 - while text_too_large: - file_path = resource_stream(__name__, 'fonts/{}.ttf'.format(font)) - try: - fnt = ImageFont.truetype(file_path, font_size) - except Exception: - fnt = ImageFont.load_default() - - draw = ImageDraw.Draw(tmpimg) - left, top, w, h = draw.textbbox((0, 0), text=text, font=fnt) - if w < max_size[0] - 4 and h < max_size[1] - 4: - text_too_large = False - font_size -= 1 - else: - file_path = resource_stream(__name__, 'fonts/{}.ttf'.format(font)) - try: - fnt = ImageFont.truetype(file_path, font_size) - except Exception: - fnt = ImageFont.load_default() - draw = ImageDraw.Draw(tmpimg) - left, top, w, h = draw.textbbox((0, 0), text=text, font=fnt) - - img = Image.new('P', (w, h), 'white') - draw = ImageDraw.Draw(img) - draw.text((0, 0), text, font=fnt, fill='black') - return img - - -def get_concat(im1, im2, direction='right'): - if direction == 'right' or direction == 'left': - width = im1.width + im2.width - height = max(im1.height, im2.height) - elif direction == 'down' or direction == 'up': - width = max(im1.width, im2.width) - height = im1.height + im2.height - else: - raise ValueError( - 'Invalid direction "{}" (must be one of "left", "right", "up", or "down")'.format(direction) - ) - - dst = Image.new('L', (width, height), 'white') - - if direction == 'right' or direction == 'left': - if im1.height > im2.height: - im1_y = 0 - im2_y = abs(im1.height-im2.height) // 2 - else: - im1_y = abs(im1.height-im2.height) // 2 - im2_y = 0 - - if direction == 'right': - im1_x = 0 - im2_x = im1.width - else: - im1_x = im2.width - im2_x = 0 - elif direction == 'up' or direction == 'down': - if im1.width > im2.width: - im1_x = 0 - im2_x = abs(im1.width-im2.width) // 2 - else: - im1_x = abs(im1.width-im2.width) // 2 - im2_x = 0 - - if direction == 'down': - im1_y = 0 - im2_y = im1.height - else: - im1_y = im2.height - im2_y = 0 - else: - raise ValueError( - 'Invalid direction "{}" (must be one of "left", "right", "up", or "down")'.format(direction) - ) - - dst.paste(im1, (im1_x, im1_y)) - dst.paste(im2, (im2_x, im2_y)) - - return dst + return str(base64.b64encode(stream.getvalue()), encoding='ascii') \ No newline at end of file From ffba5b444db438ee6705538b622b6a60bb355440 Mon Sep 17 00:00:00 2001 From: LHBL2003 Date: Tue, 13 Aug 2024 16:22:09 +0200 Subject: [PATCH 02/14] Support font_weight --- netbox_qrcode/__init__.py | 3 +- netbox_qrcode/template_content.py | 1 + .../templates/netbox_qrcode/qrcode3.html | 60 +++++++++---------- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/netbox_qrcode/__init__.py b/netbox_qrcode/__init__.py index 3975ba4..7f1f283 100644 --- a/netbox_qrcode/__init__.py +++ b/netbox_qrcode/__init__.py @@ -15,8 +15,9 @@ class QRCodeConfig(PluginConfig): 'with_text': True, 'with_qr': True, 'text_fields': ['name', 'serial'], - 'font': '\'Trebuchet MS\', sans-serif', + 'font': 'TahomaBold', 'font_size': '3mm', + 'font_weight': 'normal', 'custom_text': None, 'text_location': 'right', diff --git a/netbox_qrcode/template_content.py b/netbox_qrcode/template_content.py index 0d6073b..96d282e 100644 --- a/netbox_qrcode/template_content.py +++ b/netbox_qrcode/template_content.py @@ -54,6 +54,7 @@ def Create_SubPluginContent(self, labelDesignNo): 'text_location': config.get('text_location'), 'font': config.get('font'), 'font_size': config.get('font_size'), + 'font_weight': config.get('font_weight'), 'with_qr': config.get('with_qr'), 'label_qr_width': config.get('label_qr_width'), 'label_qr_height': config.get('label_qr_height'), diff --git a/netbox_qrcode/templates/netbox_qrcode/qrcode3.html b/netbox_qrcode/templates/netbox_qrcode/qrcode3.html index ec6cdae..0001c03 100644 --- a/netbox_qrcode/templates/netbox_qrcode/qrcode3.html +++ b/netbox_qrcode/templates/netbox_qrcode/qrcode3.html @@ -15,10 +15,10 @@ '; + var printContent = document.getElementById(areaID).innerHTML; var originalContent = document.body.innerHTML; + document.body.innerHTML = printContent; + //document.body.innerHTML += css; window.print(); document.body.innerHTML = originalContent; } @@ -13,6 +27,7 @@ ' + '' + '' + '' + '' ``` \ No newline at end of file diff --git a/docs/img/Configuration_Label_Example_12.png b/docs/img/Configuration_Label_Example_12.png new file mode 100644 index 0000000000000000000000000000000000000000..8585935cb5684d8dee6a05f5ccddd591af2207bc GIT binary patch literal 11811 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