diff --git a/MANIFEST.in b/MANIFEST.in index b9dca47..faa226a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ recursive-include netbox_qrcode/templates *.html -recursive-include netbox_qrcode/fonts *.ttf \ No newline at end of file +recursive-include netbox_qrcode/fonts *.ttf +recursive-include netbox_qrcode/migrations *.py \ No newline at end of file diff --git a/netbox_qrcode/__init__.py b/netbox_qrcode/__init__.py index 9018347..b07887e 100644 --- a/netbox_qrcode/__init__.py +++ b/netbox_qrcode/__init__.py @@ -1,10 +1,9 @@ from extras.plugins import PluginConfig from .version import __version__ - class QRCodeConfig(PluginConfig): name = 'netbox_qrcode' - verbose_name = 'qrcode' + verbose_name = 'QR Code View' description = 'Generate QR codes for the objects' version = __version__ author = 'Nikolay Yuzefovich' diff --git a/netbox_qrcode/admin.py b/netbox_qrcode/admin.py new file mode 100644 index 0000000..aa32e44 --- /dev/null +++ b/netbox_qrcode/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from .models import QRExtendedDevice, QRExtendedRack, QRExtendedCable + +@admin.register(QRExtendedDevice) +class QRExtendedDeviceAdmin(admin.ModelAdmin): + list_display = ("device", "photo") + +@admin.register(QRExtendedRack) +class QRExtendedRackAdmin(admin.ModelAdmin): + list_display = ("rack", "photo") + +@admin.register(QRExtendedCable) +class QRExtendedCableAdmin(admin.ModelAdmin): + list_display = ("cable", "photo") diff --git a/netbox_qrcode/filters.py b/netbox_qrcode/filters.py new file mode 100644 index 0000000..701e41c --- /dev/null +++ b/netbox_qrcode/filters.py @@ -0,0 +1,115 @@ +import django_filters +from django.db import models + +from dcim.models import Site, Region, Rack, Manufacturer, RackRole +from dcim.choices import DeviceStatusChoices, RackStatusChoices + +from utilities.filters import TreeNodeMultipleChoiceFilter + +from .models import QRExtendedDevice, QRExtendedRack, QRExtendedCable + +class BaseFiltersSet(django_filters.FilterSet): + + q = django_filters.CharFilter( + method='search', + label='Search', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region', + lookup_expr='in', + to_field_name='slug', + label='Region (slug)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) + +# Recieves QuerySet in Views.py and filters based on form values, returns the resulting filtered queryset back to views.py +class SearchDeviceFilterSet(BaseFiltersSet): + + status = django_filters.MultipleChoiceFilter( + choices=DeviceStatusChoices, + null_value=None + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset= QRExtendedDevice.objects.all(), + to_field_name='id', + field_name='id', + label='Device (ID)', + ) + rack_id = django_filters.ModelMultipleChoiceFilter( + field_name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='device_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + + class Meta: + model = QRExtendedDevice + fields = ['id', ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + models.Q(name__icontains=value) + ) + +class SearchRackFilterSet(BaseFiltersSet): + + status = django_filters.MultipleChoiceFilter( + choices=RackStatusChoices, + null_value=None + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=RackRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + + class Meta: + model = QRExtendedRack + fields = ['id', ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + models.Q(name__icontains=value) + ) + +class SearchCableFilterSet(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = QRExtendedCable + fields = ['id', ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(label__icontains=value) + + def filter_device(self, queryset, name, value): + queryset = queryset.filter( + models.Q(**{'_termination_a_{}__in'.format(name): value}) | + models.Q(**{'_termination_b_{}__in'.format(name): value}) + ) + return queryset + + + + diff --git a/netbox_qrcode/forms.py b/netbox_qrcode/forms.py new file mode 100644 index 0000000..a5cbe3d --- /dev/null +++ b/netbox_qrcode/forms.py @@ -0,0 +1,78 @@ +from django import forms +from dcim.models import Device, Site, Region, Rack, RackRole, Manufacturer +from dcim.choices import DeviceStatusChoices, RackStatusChoices + +from utilities.forms import DynamicModelMultipleChoiceField, StaticSelect2Multiple, DynamicModelChoiceField + + +class BaseFilterForm(forms.Form): + q = forms.CharField( + required=False, + label='Search' + ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + required=False, + query_params={ + 'region': '$region' + } + ) + + +class SearchFilterFormDevice(BaseFilterForm): + + status = forms.MultipleChoiceField( + choices=DeviceStatusChoices, + required=False, + widget=StaticSelect2Multiple() + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + to_field_name='id', + required=False, + label='Device', + null_option='None', + ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label='Rack', + null_option='None', + ) + manufacturer = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='slug', + required=False, + label='Manufacturer' + ) + + +class SearchFilterFormRack(BaseFilterForm): + + status = forms.MultipleChoiceField( + choices=RackStatusChoices, + required=False, + widget=StaticSelect2Multiple() + ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + to_field_name='slug', + required=False, + null_option='None', + ) + + +class SearchFilterFormCable(forms.Form): + q = forms.CharField( + required=False, + label='Search' + ) diff --git a/netbox_qrcode/migrations/0001_initial.py b/netbox_qrcode/migrations/0001_initial.py new file mode 100644 index 0000000..8c7a879 --- /dev/null +++ b/netbox_qrcode/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2 on 2021-06-22 19:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0130_sitegroup'), + ] + + operations = [ + migrations.CreateModel( + name='QRExtendedRack', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('photo', models.ImageField(upload_to='image-attachments/')), + ('url', models.URLField(default='')), + ('name', models.CharField(blank=True, max_length=100, null=True)), + ('status', models.CharField(default='active', max_length=50)), + ('rack', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.rackrole')), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.site')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='QRExtendedDevice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('photo', models.ImageField(upload_to='image-attachments/')), + ('url', models.URLField(default='')), + ('name', models.CharField(blank=True, max_length=64, null=True)), + ('status', models.CharField(default='active', max_length=50)), + ('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')), + ('device_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.devicerole')), + ('device_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.devicetype')), + ('rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.rack')), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.site')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='QRExtendedCable', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('photo', models.ImageField(upload_to='image-attachments/')), + ('url', models.URLField(default='')), + ('cable', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.cable')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/netbox_qrcode/migrations/__init__.py b/netbox_qrcode/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_qrcode/models.py b/netbox_qrcode/models.py new file mode 100644 index 0000000..c96243b --- /dev/null +++ b/netbox_qrcode/models.py @@ -0,0 +1,114 @@ +# Extends netbox models with Custom models +from django.db import models +from django.urls import reverse + +from dcim.choices import DeviceStatusChoices, RackStatusChoices, CableStatusChoices +from ipam.choices import * + + +# Abstract class which extended objects extend from +class QRObject(models.Model): + + photo = models.ImageField(upload_to='image-attachments/') + url = models.URLField(default='', max_length=200) + + class Meta: + abstract = True + +# Devices Wrapper +class QRExtendedDevice(QRObject): + device = models.ForeignKey( + to="dcim.Device", on_delete=models.CASCADE, null=True) + + name = models.CharField( + max_length=64, + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=DeviceStatusChoices, + default=DeviceStatusChoices.STATUS_ACTIVE + ) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + device_role = models.ForeignKey( + to='dcim.DeviceRole', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + # Set link for id column in QRDeviceTable to be the return url formatted with device's pk + def get_absolute_url(self): + return reverse('dcim:device', args=[self.device.pk]) + + def get_status_class(self): + return DeviceStatusChoices.CSS_CLASSES.get(self.status) + +# Racks Wrapper +class QRExtendedRack(QRObject): + rack = models.ForeignKey( + to="dcim.Rack", on_delete=models.CASCADE, null=True) + + name = models.CharField( + max_length=100, + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=RackStatusChoices, + default=RackStatusChoices.STATUS_ACTIVE + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + role = models.ForeignKey( + to='dcim.RackRole', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True, + help_text='Functional role' + ) + + def get_absolute_url(self): + return reverse('dcim:rack', args=[self.rack.pk]) + + def get_status_class(self): + return RackStatusChoices.CSS_CLASSES.get(self.status) + +# Cables Wrapper +class QRExtendedCable(QRObject): + cable = models.ForeignKey( + to="dcim.Cable", on_delete=models.CASCADE, null=True) + + def get_absolute_url(self): + return reverse('dcim:cable', args=[self.cable.pk]) + + def get_status_class(self): + return CableStatusChoices.CSS_CLASSES.get(self.status) diff --git a/netbox_qrcode/navigation.py b/netbox_qrcode/navigation.py new file mode 100644 index 0000000..54db529 --- /dev/null +++ b/netbox_qrcode/navigation.py @@ -0,0 +1,9 @@ +from extras.plugins import PluginMenuItem + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_qrcode:qrcode_devices', + link_text='QR Codes', + buttons=() + ), +) \ No newline at end of file diff --git a/netbox_qrcode/tables.py b/netbox_qrcode/tables.py new file mode 100644 index 0000000..166b454 --- /dev/null +++ b/netbox_qrcode/tables.py @@ -0,0 +1,83 @@ +import django_tables2 as tables +from utilities.tables import BaseTable, ChoiceFieldColumn, ColoredLabelColumn, ToggleColumn +from .models import QRExtendedDevice, QRExtendedRack, QRExtendedCable + +# Device Table +class QRDeviceTables(BaseTable): + """Table for displaying Device objects.""" + + # Set up hyperlinks to column items + pk = ToggleColumn(visible=True) + device = tables.LinkColumn() + status = ChoiceFieldColumn() + device_role = ColoredLabelColumn() + device_type = tables.LinkColumn() + rack = tables.LinkColumn() + site = tables.LinkColumn() + id = tables.LinkColumn() + url = tables.TemplateColumn(' ', verbose_name = 'QR Code') + + # Netbox base table class, fields display column names/order + class Meta(BaseTable.Meta): + model = QRExtendedDevice + fields = ( + "pk", + "device", + "status", + "device_role", + "device_type", + "rack", + "site", + "id", + "photo", + "url", + ) + +# Rack Table +class QRRackTables(BaseTable): + """Table for displaying Rack objects.""" + + # Set up hyperlinks to column items + pk = ToggleColumn(visible=True) + rack = tables.LinkColumn() + status = ChoiceFieldColumn() + site = tables.LinkColumn() + role = ColoredLabelColumn() + id = tables.LinkColumn() + url = tables.TemplateColumn(' ', verbose_name = 'QR Code') + + # Netbox base table class, fields display column names/order + class Meta(BaseTable.Meta): + model = QRExtendedRack + fields = ( + "pk", + "rack", + "site", + "status", + "role", + "id", + "photo", + "url", + ) + +# Cable Table +class QRCableTables(BaseTable): + """Table for displaying Cable objects.""" + + # Set up hyperlinks to column items + pk = ToggleColumn(visible=True) + cable = tables.LinkColumn() + # status = ChoiceFieldColumn() + id = tables.LinkColumn() + url = tables.TemplateColumn(' ', verbose_name = 'QR Code') + + # Netbox base table class, fields display column names/order + class Meta(BaseTable.Meta): + model = QRExtendedCable + fields = ( + "pk", + "cable", + "id", + "photo", + "url", + ) \ No newline at end of file diff --git a/netbox_qrcode/template_content.py b/netbox_qrcode/template_content.py index 0bf9e64..c83d977 100644 --- a/netbox_qrcode/template_content.py +++ b/netbox_qrcode/template_content.py @@ -1,10 +1,8 @@ - from django.core.exceptions import ObjectDoesNotExist from extras.plugins import PluginTemplateExtension from .utilities import get_img_b64, get_qr, get_qr_text, get_concat - class QRCode(PluginTemplateExtension): def x_page(self): @@ -12,11 +10,11 @@ def x_page(self): obj = self.context['object'] request = self.context['request'] url = request.build_absolute_uri(obj.get_absolute_url()) - # get object settings + # Get object config settings obj_cfg = config.get(self.model.replace('dcim.', '')) if obj_cfg is None: return '' - # and ovverride default + # and override default config config.update(obj_cfg) qr_args = {} @@ -24,7 +22,10 @@ def x_page(self): if k.startswith('qr_'): qr_args[k.replace('qr_', '')] = v + # Create qr image qr_img = get_qr(url, **qr_args) + + # Handle qr text if enabled if config.get('with_text'): text = [] for text_field in config.get('text_fields', []): @@ -47,10 +48,16 @@ def x_page(self): if custom_text: text.append(custom_text) text = '\n'.join(text) + + # Create qr text with image size and text text_img = get_qr_text(qr_img.size, text, config.get('font')) + + # Combine qr image and qr text qr_with_text = get_concat(qr_img, text_img, config.get('text_location', 'right')) + # Convert png to base 64 image img = get_img_b64(qr_with_text) + else: img = get_img_b64(qr_img) try: diff --git a/netbox_qrcode/templates/netbox_qrcode/cables.html b/netbox_qrcode/templates/netbox_qrcode/cables.html new file mode 100644 index 0000000..e3a67a3 --- /dev/null +++ b/netbox_qrcode/templates/netbox_qrcode/cables.html @@ -0,0 +1,42 @@ + +{% extends 'netbox_qrcode/qrbase.html' %}{% load render_table from django_tables2 %} +{% block qr_menu_content %} + + +
+
+ +
+ Devices + Racks + Cables +
+ +
+ +
+ {% csrf_token %} +

{% block title %}Cables{% endblock %}

+ +
+
+ + {% render_table table_cable %} + + +
+
+ + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/netbox_qrcode/templates/netbox_qrcode/devices.html b/netbox_qrcode/templates/netbox_qrcode/devices.html new file mode 100644 index 0000000..350473e --- /dev/null +++ b/netbox_qrcode/templates/netbox_qrcode/devices.html @@ -0,0 +1,42 @@ + +{% extends 'netbox_qrcode/qrbase.html' %}{% load render_table from django_tables2 %} +{% block qr_menu_content %} + + +
+
+ +
+ Devices + Racks + Cables +
+ +
+ +
+ {% csrf_token %} +

{% block title %}Devices{% endblock %}

+ +
+
+ + {% render_table table_device %} + + +
+
+ + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/netbox_qrcode/templates/netbox_qrcode/print.html b/netbox_qrcode/templates/netbox_qrcode/print.html new file mode 100644 index 0000000..316451e --- /dev/null +++ b/netbox_qrcode/templates/netbox_qrcode/print.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} +{% load render_table from django_tables2 %} +{% block content %} + + + + + +
+
+
+
+ Review Printed {{name}} +
+
+ +
+
+ + +
+
+
{% include 'inc/table.html' %}
+
+
+
+
+ +
+ +
+
+
+ +{% for img in image%} +
+
+

Page {{ forloop.counter }}

+ +
+
+{% endfor%} + +{% endblock %} diff --git a/netbox_qrcode/templates/netbox_qrcode/qrbase.html b/netbox_qrcode/templates/netbox_qrcode/qrbase.html new file mode 100644 index 0000000..efaeaaa --- /dev/null +++ b/netbox_qrcode/templates/netbox_qrcode/qrbase.html @@ -0,0 +1,86 @@ + +{% extends 'base.html' %} +{% block content %} + +

Netbox QR Code Menu

+ + + +{% block qr_menu_content %}{% endblock %} + + +
+ {% csrf_token %} + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + +
+ + + {{ successMessage |safe }} + +
+ + +
+ {% load form_helpers %} + {% include 'inc/search_panel.html' %} +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/netbox_qrcode/templates/netbox_qrcode/qrcode.html b/netbox_qrcode/templates/netbox_qrcode/qrcode.html index 40a3ed6..6090622 100644 --- a/netbox_qrcode/templates/netbox_qrcode/qrcode.html +++ b/netbox_qrcode/templates/netbox_qrcode/qrcode.html @@ -1,20 +1,27 @@
-
- QR Code -
-
- -
- -
\ No newline at end of file +
+ QR Code +
+
+
+ Download +
+ +
diff --git a/netbox_qrcode/templates/netbox_qrcode/racks.html b/netbox_qrcode/templates/netbox_qrcode/racks.html new file mode 100644 index 0000000..1900ddd --- /dev/null +++ b/netbox_qrcode/templates/netbox_qrcode/racks.html @@ -0,0 +1,42 @@ + +{% extends 'netbox_qrcode/qrbase.html' %}{% load render_table from django_tables2 %} +{% block qr_menu_content %} + + +
+
+ +
+ Devices + Racks + Cables +
+ +
+ +
+ {% csrf_token %} +

{% block title %}Racks{% endblock %}

+ +
+
+ + {% render_table table_rack %} + + +
+
+ + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/netbox_qrcode/urls.py b/netbox_qrcode/urls.py new file mode 100644 index 0000000..b1722a6 --- /dev/null +++ b/netbox_qrcode/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('devices/', views.QRcodeDeviceView.as_view(), name='qrcode_devices'), + path('racks/', views.QRcodeRackView.as_view(), name='qrcode_racks'), + path('cables/', views.QRcodeCableView.as_view(), name='qrcode_cables'), + + path('devices/print/', views.PrintView.as_view(), name='print_menu'), + path('racks/print/', views.PrintView.as_view(), name='print_menu'), + path('cables/print/', views.PrintView.as_view(), name='print_menu'), +] \ No newline at end of file diff --git a/netbox_qrcode/utilities.py b/netbox_qrcode/utilities.py index ad621ce..a23bef2 100644 --- a/netbox_qrcode/utilities.py +++ b/netbox_qrcode/utilities.py @@ -28,26 +28,25 @@ def get_img_b64(img): return str(base64.b64encode(stream.getvalue()), encoding='ascii') -def get_qr_text(max_size, text, font='TahomaBold'): - font_size = 56 - tmpimg = Image.new('L', max_size, 'white') - text_too_large = True - while text_too_large: +def get_qr_text(size, text, font='ArialMT', font_size=100): + img = Image.new('L', size, 'white') + flag = True + while flag: file_path = resource_stream(__name__, 'fonts/{}.ttf'.format(font)) try: - fnt = ImageFont.truetype(file_path, font_size) + fnt = ImageFont.truetype(file_path,font_size) except Exception: fnt = ImageFont.load_default() + flag = False - draw = ImageDraw.Draw(tmpimg) + draw = ImageDraw.Draw(img) w, h = draw.textsize(text, font=fnt) - if w < max_size[0] - 4 and h < max_size[1] - 4: - text_too_large = False + if w < size[0] - 4 and h < size[1] - 4: + flag = False font_size -= 1 + W, H = size + draw.text(((W-w)/2, (H-h)/2), text, font=fnt, fill='black') - img = Image.new('L', (w, h), 'white') - draw = ImageDraw.Draw(img) - draw.text((0, 0), text, font=fnt, fill='black') return img @@ -102,3 +101,23 @@ def get_concat(im1, im2, direction='right'): dst.paste(im2, (im2_x, im2_y)) return dst + + +def get_concat_v(im1, im2): + dst = Image.new('L', (im1.width, im1.height + im2.height), 'white') + dst.paste(im1, (0, 0)) + dst.paste(im2, (0, im1.height)) + return dst + + +def add_print_padding_left(img, padding): + blank = Image.new('L', (padding, img.height), 'white') + img = get_concat(blank, img) + return img + + +def add_print_padding_v(img, padding): + blank = Image.new('L', (img.width, padding), 'white') + img = get_concat_v(blank, img) + img = get_concat_v(img, blank) + return img \ No newline at end of file diff --git a/netbox_qrcode/version.py b/netbox_qrcode/version.py index 034f46c..d85b28e 100644 --- a/netbox_qrcode/version.py +++ b/netbox_qrcode/version.py @@ -1 +1 @@ -__version__ = "0.0.6" +__version__ = "0.0.6" \ No newline at end of file diff --git a/netbox_qrcode/views.py b/netbox_qrcode/views.py new file mode 100644 index 0000000..f36cf07 --- /dev/null +++ b/netbox_qrcode/views.py @@ -0,0 +1,513 @@ +import requests + +from django.shortcuts import redirect, render +from django.views import View +from django_tables2 import RequestConfig + +from dcim.models import Device, Rack, Cable +from dcim.tables import DeviceTable, RackTable, CableTable + +from . import forms, filters + +from .tables import QRDeviceTables, QRRackTables, QRCableTables +from .models import QRExtendedDevice, QRExtendedRack, QRExtendedCable + +from PIL import Image +from .utilities import * + +from django.conf import settings + + +class QRcodeDeviceView(View): + template_name = 'netbox_qrcode/devices.html' + filterset_device = filters.SearchDeviceFilterSet + + def get(self, request): + # Clear all objects in case of duplicate key violation + QRExtendedDevice.objects.all().delete() + + base_url = request.build_absolute_uri('/') + 'media/image-attachments/' + + # Find all current Devices and instantiates new models that provide links to photos + for device in Device.objects.all().iterator(): + + # Create device with resized url + url_resized = '{}resized{}.png'.format(base_url, device.name) + QRExtendedDevice.objects.get_or_create( + id=device.id, + device=device, + name=device.name, + status=device.status, + device_type=device.device_type, + device_role=device.device_role, + site=device.site, + rack=device.rack, + photo='image-attachments/{}.png'.format(device.name), + url=url_resized + ) + + # Create QuerySets from extended models + queryset_device = QRExtendedDevice.objects.all() + + # Filter QuerySets + queryset_device = self.filterset_device( + request.GET, queryset_device).qs + + # Create Tables for each separate object's querysets + table_device = QRDeviceTables(queryset_device) + + # Paginate Tables + RequestConfig(request, paginate={ + "per_page": 50}).configure(table_device) + + # Render html with context + return render(request, self.template_name, { + 'table_device': table_device, + 'filter_form': forms.SearchFilterFormDevice( + request.GET, + label_suffix='' + ), + }) + + def post(self, request): + + # Get slider values + font_size = request.POST.get('font-size-range') + box_size = request.POST.get('box-size-range') + border_size = request.POST.get('border-size-range') + + # Reload all Object QR Images with input parameters + numReloaded = reloadQRImages(request, Device, "devices", int( + font_size), int(box_size), int(border_size)) + + # Create QuerySets from extended models + queryset_device = QRExtendedDevice.objects.all() + + # Filter QuerySets + queryset_device = self.filterset_device( + request.GET, queryset_device).qs + + # Create Tables for each separate object's querysets + table_device = QRDeviceTables(queryset_device) + + # Paginate Tables + RequestConfig(request, paginate={ + "per_page": 50}).configure(table_device) + + # Render html with context + return render(request, self.template_name, { + 'table_device': table_device, + 'filter_form': forms.SearchFilterFormDevice( + request.GET, + label_suffix='' + ), + 'successMessage': '
Successfully Reloaded {} Devices
'.format(numReloaded), + }) + + +class QRcodeRackView(View): + template_name = 'netbox_qrcode/racks.html' + filterset_rack = filters.SearchRackFilterSet + + def get(self, request): + # Clear all objects in case of duplicate key violation + QRExtendedRack.objects.all().delete() + + base_url = request.build_absolute_uri('/') + 'media/image-attachments/' + + # Find all current Racks and instantiates new models that provide links to photos + for rack in Rack.objects.all().iterator(): + + # Create rack with resized url + url_resized = '{}resized{}.png'.format(base_url, rack.name) + QRExtendedRack.objects.get_or_create( + id=rack.id, + rack=rack, + name=rack.name, + status=rack.status, + site=rack.site, + role=rack.role, + photo='image-attachments/{}.png'.format(rack.name), + url=url_resized + ) + + # Create QuerySets from extended models + queryset_rack = QRExtendedRack.objects.all() + + # Filter QuerySets + queryset_rack = self.filterset_rack(request.GET, queryset_rack).qs + + # Create Tables for each separate object's querysets + table_rack = QRRackTables(queryset_rack) + + # Paginate Tables + RequestConfig(request, paginate={"per_page": 50}).configure(table_rack) + + # Render html with context + return render(request, self.template_name, { + 'table_rack': table_rack, + 'filter_form': forms.SearchFilterFormRack( + request.GET, + label_suffix='' + ), + }) + + def post(self, request): + # Get slider values + font_size = request.POST.get('font-size-range') + box_size = request.POST.get('box-size-range') + border_size = request.POST.get('border-size-range') + + # Reload all Object QR Images + numReloaded = reloadQRImages(request, Rack, "racks", int( + font_size), int(box_size), int(border_size)) + + # Create QuerySets from extended models + queryset_rack = QRExtendedRack.objects.all() + + # Filter QuerySets + queryset_rack = self.filterset_rack(request.GET, queryset_rack).qs + + # Create Tables for each separate object's querysets + table_rack = QRRackTables(queryset_rack) + + # Paginate Tables + RequestConfig(request, paginate={"per_page": 50}).configure(table_rack) + + return render(request, self.template_name, { + 'table_rack': table_rack, + 'filter_form': forms.SearchFilterFormRack( + request.GET, + label_suffix='' + ), + 'successMessage': '
Successfully Reloaded {} Racks
'.format(numReloaded), + }) + + +class QRcodeCableView(View): + template_name = 'netbox_qrcode/cables.html' + filterset_cable = filters.SearchCableFilterSet + + def get(self, request): + # Clear all objects in case of duplicate key violation + QRExtendedCable.objects.all().delete() + + base_url = request.build_absolute_uri('/') + 'media/image-attachments/' + + # Find all current Cables and instantiates new models that provide links to photos + for cable in Cable.objects.all().iterator(): + + # Create cable with resized url + url_resized = '{}resized{}.png'.format(base_url, cable.name) + QRExtendedCable.objects.get_or_create( + id=cable.id, + cable=cable, + name=cable.name, + photo='image-attachments/{}.png'.format(cable.name), + url=url_resized + ) + + # Create QuerySets from extended models + queryset_cable = QRExtendedCable.objects.all() + + # Filter QuerySets + queryset_cable = self.filterset_cable(request.GET, queryset_cable).qs + + # Create Tables for each separate object's querysets + table_cable = QRCableTables(queryset_cable) + + # Paginate Tables + RequestConfig(request, paginate={ + "per_page": 50}).configure(table_cable) + + # Render html with context + return render(request, self.template_name, { + 'table_cable': table_cable, + 'filter_form': forms.SearchFilterFormCable( + request.GET, + label_suffix='' + ), + }) + + def post(self, request): + # Get slider values + font_size = request.POST.get('font-size-range') + box_size = request.POST.get('box-size-range') + border_size = request.POST.get('border-size-range') + + # Reload all Object QR Images + numReloaded = reloadQRImages(request, Cable, "cables", int( + font_size), int(box_size), int(border_size)) + + # Create QuerySets from extended models + queryset_cable = QRExtendedCable.objects.all() + + # Filter QuerySets + queryset_cable = self.filterset_cable(request.GET, queryset_cable).qs + + # Create Tables for each separate object's querysets + table_cable = QRCableTables(queryset_cable) + + # Paginate Tables + RequestConfig(request, paginate={ + "per_page": 50}).configure(table_cable) + + # Render html with context + return render(request, self.template_name, { + 'table_cable': table_cable, + 'filter_form': forms.SearchFilterFormCable( + request.GET, + label_suffix='' + ), + 'successMessage': '
Successfully Reloaded {} Cables
'.format(numReloaded), + }) + + +# View for when 'Print Selected' Button Pressed +class PrintView(View): + + template_name = 'netbox_qrcode/print.html' + + # Collect post form content from menu page + def post(self, request): + + context = {} + pk_list = request.POST.getlist('pk') + + # Get type of netbox object being printed + name = request.POST.get('obj_type') + context['name'] = name + + # Switch model based on object type + obj_dict = {"Devices": Device, "Racks": Rack, "Cables": Cable} + Model = obj_dict[name] + + # Switch table based on object type + table_dict = {"Devices": DeviceTable, + "Racks": RackTable, "Cables": CableTable} + object_queryset = Model.objects.filter(pk__in=pk_list) + context['table'] = table_dict[name](object_queryset) + + # Number of Images Selected to print + image_count = len(pk_list) + + # Set images with or without text should be used and build url + without_text = request.POST.get('without_text') + + + def combine_rows(imageRow, numRows): + """ + Concatenates rows into pages + :param imageRow: List of rows of images + :param numRows: Number of rows in a page + :return: List of pages of images + """ + + image_pages = [] + + final_image = imageRow[0] + i = 1 + + while i < len(imageRow): + final_image = get_concat_v(final_image, imageRow[i]) + i += 1 + + # If page full, push to page image and reset current + if (i % numRows) == 0 and i > 0: + image_pages.append(get_img_b64(final_image)) + # If more rows to handle, setup next page image + if i < len(imageRow): + final_image = imageRow[i] + i += 1 + + if i % numRows != 0: + # Convert and add as page + image_pages.append(get_img_b64(final_image)) + + return image_pages + + # No text setting option selected + if without_text: + base_url = request.build_absolute_uri( + '/') + 'media/image-attachments/noText' + rowSize = 6 + numRows = 8 + horizontal_print_padding = 40 + footer_text_height = 20 + text_padding = 10 + context['without_text'] = 1 + # Print with text enabled and with below settings + else: + base_url = request.build_absolute_uri( + '/') + 'media/image-attachments/' + rowSize = 3 + numRows = 10 + horizontal_print_padding = 110 + vertical_print_padding = 5 + context['without_text'] = 0 + + # Multiple selected, account for multi page print + if image_count > 0: + image_curr = [] + image_rows = [] + image_rows_combined = [] + + for i in range(image_count): + + obj = Model.objects.get(pk=pk_list[i]) + url = '{}{}.png'.format(base_url, obj.name) + image = Image.open(requests.get(url, stream=True).raw) + + # Append info to bottom of image using user config font if no text QR + if without_text: + text_img = get_qr_text((image.width, footer_text_height), obj.name, settings.PLUGINS_CONFIG.get( + 'netbox_qrcode', {}).get('font'), 200) + text_img = add_print_padding_v(text_img, text_padding) + image = get_concat_v(image, text_img) + + # Resize text Qr and add vertical padding + else: + resize_width_height = (162, 88) + image = image.resize(resize_width_height) + image = add_print_padding_v(image, vertical_print_padding) + + # Append modified image + image_curr.append(image) + + # If row full, push to row list and reset current + if ((i+1) % rowSize) == 0: + image_rows.append(image_curr) + image_curr = [] + + # Append row list with remaining images if any exists + if image_curr: + # Make row full for print spacing + for i in range(0, rowSize-len(image_curr)): + blank = Image.new('L', image.size, 'white') + image_curr.append(blank) + image_rows.append(image_curr) + + # Loop through each list of rows + for row in image_rows: + + # Combine images in single row into one image + first_image = row[0] + for i in range(1, len(row)): + # Add left side padding to all but first image in row and append + row[i] = add_print_padding_left(row[i], horizontal_print_padding) + first_image = get_concat(first_image, row[i]) + + image_rows_combined.append(first_image) + + # Send list of pages to print template + context['image'] = combine_rows(image_rows_combined, numRows) + + return render(request, self.template_name, context) + + # No images selected, split current path to remove /print and redirect back to respective object page + else: + return redirect('/'.join(self.request.path_info.split('/')[:-2])) + + +def reloadQRImages(request, Model, objName, font_size=100, box_size=3, border_size=0): + """ + Creates QRcode image with text, without text, and thumbsized for netbox objects and saves to disk + :param request: network request + :param Model: netbox object + :param objName: netbox object string name + :return: number of object images created + """ + + # Collect User Config and make copy + config = settings.PLUGINS_CONFIG.get('netbox_qrcode', {}).copy() + + numReloaded = 0 + for obj in Model.objects.all().iterator(): + + # Check if qrcode already exists + image_url = request.build_absolute_uri( + '/') + 'media/image-attachments/{}.png'.format(obj.name) + rq = requests.get(image_url) + + # Create QR Code only for non-existing or if forced + if rq.status_code != 200 or request.POST.get('force-reload-all'): + numReloaded += 1 + + url = request.build_absolute_uri( + '/') + 'dcim/{}/{}'.format(objName, obj.pk) + + # Get object config settings + obj_cfg = config.get(objName[:-1]) + if obj_cfg is None: + return '' + # and override default config + config.update(obj_cfg) + + # Override User Config with print settings + printConfig = {} + printConfig['qr_box_size'] = box_size + printConfig['qr_border'] = border_size + + config.update(printConfig) + + qr_args = {} + for k, v in config.items(): + if k.startswith('qr_'): + qr_args[k.replace('qr_', '')] = v + + # Create qr image + qr_img = get_qr(url, **qr_args) + + # Handle qr text if enabled + if config.get('with_text'): + 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: + pass + 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) + + # Create qr text with image size and text + text_img = get_qr_text( + qr_img.size, text, config.get('font'), font_size) + + # Combine qr image and qr text + qr_with_text = get_concat(qr_img, text_img) + + # Save image with text to container with object's first field name + text_fields = config.get('text_fields', []) + file_path = '/opt/netbox/netbox/media/image-attachments/{}.png'.format( + getattr(obj, text_fields[0], 'default')) + qr_with_text.save(file_path) + + # Save image without text to container + file_path = '/opt/netbox/netbox/media/image-attachments/noText{}.png'.format( + getattr(obj, text_fields[0], 'default')) + resize_width_height = (90, 90) + qr_img = qr_img.resize(resize_width_height) + qr_img.save(file_path) + + # Resize final image for thumbnails and save + resize_width_height = (120, 50) + qr_with_text = qr_with_text.resize(resize_width_height) + file_path = '/opt/netbox/netbox/media/image-attachments/resized{}.png'.format( + getattr(obj, text_fields[0], 'default')) + qr_with_text.save(file_path) + + return numReloaded diff --git a/setup.py b/setup.py index 4d9b216..0eeb854 100644 --- a/setup.py +++ b/setup.py @@ -33,12 +33,12 @@ def get_version(rel_path): packages=find_packages(), include_package_data=True, package_data={ - '': ['*.ttf'], - '': ['*.html'], + '': ['*.ttf','*.html'] }, install_requires=[ 'qrcode', - 'Pillow' + 'Pillow', + 'requests', ], classifiers=[ 'Development Status :: 2 - Pre-Alpha',