From e94b33789e282eaef5c6927fb349c092ffdadf5d Mon Sep 17 00:00:00 2001 From: Johanna England Date: Mon, 6 May 2024 15:53:46 +0200 Subject: [PATCH 1/3] Add util functions to zip QR codes --- python/nav/web/utils.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/nav/web/utils.py b/python/nav/web/utils.py index e8003b4ef7..3bf1aeaf79 100644 --- a/python/nav/web/utils.py +++ b/python/nav/web/utils.py @@ -18,6 +18,7 @@ import io import os from typing import Dict, List +import zipfile from django.http import HttpResponse from django.views.generic.list import ListView @@ -114,6 +115,19 @@ def convert_bytes_buffer_to_bytes_string(bytes_buffer: io.BytesIO) -> str: return base64.b64encode(bytes_buffer.getvalue()).decode('utf-8') +def make_qr_code_byte_buffers_into_zipped_bytes(qr_codes: dict[str, io.BytesIO]): + """ + Takes a dict of the form name : qr_code as byte buffer and turns it into a ZIP file + with the names as filenames and returns that ZIP file as bytes + """ + mem_zip = io.BytesIO() + with zipfile.ZipFile(mem_zip, "w", compression=zipfile.ZIP_DEFLATED) as zip_file: + for image_name, bytes_stream in qr_codes.items(): + zip_file.writestr(image_name + ".png", bytes_stream.getvalue()) + + return mem_zip.getvalue() + + def generate_qr_codes_as_byte_strings(url_dict: Dict[str, str]) -> List[str]: """ Takes a dict of the form {name:url} and returns a list of generated QR codes as @@ -126,3 +140,15 @@ def generate_qr_codes_as_byte_strings(url_dict: Dict[str, str]) -> List[str]: convert_bytes_buffer_to_bytes_string(bytes_buffer=qr_code_byte_buffer) ) return qr_code_byte_strings + + +def generate_qr_codes_as_zip_file(url_dict: dict[str, str]) -> bytes: + """ + Takes a dict of the form {name:url} and optionally a file name and returns a ZIP + file containing QR codes as PNGs with the given file name as name + """ + qr_codes_dict = dict() + for caption, url in url_dict.items(): + qr_codes_dict[caption] = generate_qr_code(url=url, caption=caption) + + return make_qr_code_byte_buffers_into_zipped_bytes(qr_codes=qr_codes_dict) From c6dcaaaefadc014e6b712cf810679449a832abbe Mon Sep 17 00:00:00 2001 From: Johanna England Date: Mon, 6 May 2024 15:57:15 +0200 Subject: [PATCH 2/3] Add 'Generate QR codes for selected' button to SeedDB --- python/nav/web/seeddb/__init__.py | 2 ++ python/nav/web/seeddb/page/netbox/__init__.py | 1 + python/nav/web/seeddb/page/room.py | 1 + python/nav/web/templates/seeddb/list.html | 5 ++++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/python/nav/web/seeddb/__init__.py b/python/nav/web/seeddb/__init__.py index 1c65290f27..e49f1be668 100644 --- a/python/nav/web/seeddb/__init__.py +++ b/python/nav/web/seeddb/__init__.py @@ -47,6 +47,7 @@ class SeeddbInfo(object): hide_move = False hide_delete = False + hide_qr_code = True copy_url_name = None delete_url = None delete_url_name = None @@ -79,6 +80,7 @@ def template_context(self): 'tab_template': self.tab_template, 'hide_move': self.hide_move, 'hide_delete': self.hide_delete, + 'hide_qr_code': self.hide_qr_code, 'delete_url': self.delete_url, 'delete_url_name': self.delete_url_name, 'back_url': self.back_url, diff --git a/python/nav/web/seeddb/page/netbox/__init__.py b/python/nav/web/seeddb/page/netbox/__init__.py index 19bb38e561..ad8ce4ab1c 100644 --- a/python/nav/web/seeddb/page/netbox/__init__.py +++ b/python/nav/web/seeddb/page/netbox/__init__.py @@ -48,6 +48,7 @@ class NetboxInfo(SeeddbInfo): add_url = reverse_lazy('seeddb-netbox-edit') bulk_url = reverse_lazy('seeddb-netbox-bulk') copy_url_name = 'seeddb-netbox-copy' + hide_qr_code = False def netbox(request): diff --git a/python/nav/web/seeddb/page/room.py b/python/nav/web/seeddb/page/room.py index 03abe94cac..a5624a3d48 100644 --- a/python/nav/web/seeddb/page/room.py +++ b/python/nav/web/seeddb/page/room.py @@ -50,6 +50,7 @@ class RoomInfo(SeeddbInfo): add_url = reverse_lazy('seeddb-room-edit') bulk_url = reverse_lazy('seeddb-room-bulk') copy_url_name = 'seeddb-room-copy' + hide_qr_code = False def room(request): diff --git a/python/nav/web/templates/seeddb/list.html b/python/nav/web/templates/seeddb/list.html index 9e38d027b8..609dfca722 100644 --- a/python/nav/web/templates/seeddb/list.html +++ b/python/nav/web/templates/seeddb/list.html @@ -16,7 +16,7 @@ {% endif %}
- {% if not hide_move or not hide_delete %} + {% if not hide_move or not hide_delete or not hide_qr_code%}
{% if not hide_move %} @@ -24,6 +24,9 @@ {% if not hide_delete %} {% endif %} + {% if not hide_qr_code %} + + {% endif %}
{% endif %} From fb3e170065bb6ae6d397000b2896f545fbdc597d Mon Sep 17 00:00:00 2001 From: Johanna England Date: Mon, 6 May 2024 15:27:39 +0200 Subject: [PATCH 3/3] Generate ZIP file for download with QR code button --- changelog.d/+qr-code-bulk.added.md | 1 + python/nav/web/seeddb/page/__init__.py | 10 ++- python/nav/web/seeddb/page/netbox/__init__.py | 65 +++++++++++++++++- python/nav/web/seeddb/page/room.py | 49 ++++++++++++- python/nav/web/templates/seeddb/list.html | 4 ++ tests/integration/seeddb_test.py | 68 +++++++++++++++++++ 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+qr-code-bulk.added.md diff --git a/changelog.d/+qr-code-bulk.added.md b/changelog.d/+qr-code-bulk.added.md new file mode 100644 index 0000000000..2efce73dbd --- /dev/null +++ b/changelog.d/+qr-code-bulk.added.md @@ -0,0 +1 @@ +Add button to SeedDB that generates a ZIP file to download with QR Codes linking to the selected netboxes/rooms \ No newline at end of file diff --git a/python/nav/web/seeddb/page/__init__.py b/python/nav/web/seeddb/page/__init__.py index fe236c7853..58c95a1645 100644 --- a/python/nav/web/seeddb/page/__init__.py +++ b/python/nav/web/seeddb/page/__init__.py @@ -42,11 +42,19 @@ def not_implemented(*_args, **_kwargs): raise NotImplementedError() -def view_switcher(request, list_view=None, move_view=None, delete_view=None): +def view_switcher( + request, + list_view=None, + move_view=None, + delete_view=None, + generate_qr_codes_view=None, +): """Selects appropriate view depending on POST data.""" if request.method == 'POST': if 'move' in request.POST: return move_view(request) elif 'delete' in request.POST: return delete_view(request) + elif 'qr_code' in request.POST: + return generate_qr_codes_view(request) return list_view(request) diff --git a/python/nav/web/seeddb/page/netbox/__init__.py b/python/nav/web/seeddb/page/netbox/__init__.py index ad8ce4ab1c..672ae03a1a 100644 --- a/python/nav/web/seeddb/page/netbox/__init__.py +++ b/python/nav/web/seeddb/page/netbox/__init__.py @@ -18,11 +18,14 @@ import datetime from django.db import transaction from django.contrib.postgres.aggregates import ArrayAgg +from django.http import HttpResponseRedirect +from django.urls import reverse from nav.models.manage import Netbox from nav.bulkparse import NetboxBulkParser from nav.bulkimport import NetboxImporter +from nav.web.message import new_message, Messages from nav.web.seeddb import SeeddbInfo, reverse_lazy from nav.web.seeddb.constants import SEEDDB_EDITABLE_MODELS from nav.web.seeddb.page import view_switcher @@ -31,6 +34,10 @@ from nav.web.seeddb.utils.move import move from nav.web.seeddb.utils.bulk import render_bulkimport from nav.web.seeddb.page.netbox.forms import NetboxFilterForm, NetboxMoveForm +from nav.web.utils import ( + generate_qr_codes_as_byte_strings, + generate_qr_codes_as_zip_file, +) class NetboxInfo(SeeddbInfo): @@ -54,7 +61,11 @@ class NetboxInfo(SeeddbInfo): def netbox(request): """Controller for landing page for netboxes""" return view_switcher( - request, list_view=netbox_list, move_view=netbox_move, delete_view=netbox_delete + request, + list_view=netbox_list, + move_view=netbox_move, + delete_view=netbox_delete, + generate_qr_codes_view=netbox_generate_qr_codes, ) @@ -112,6 +123,58 @@ def netbox_pre_deletion_mark(queryset): queryset.update(deleted_at=datetime.datetime.now(), up_to_date=False) +def netbox_generate_qr_codes(request): + """Controller for generating qr codes for netboxes""" + if not request.POST.getlist('object'): + new_message( + request, + "You need to select at least one object to generate qr codes for", + Messages.ERROR, + ) + return HttpResponseRedirect(reverse('seeddb-room')) + + url_dict = dict() + netboxes = Netbox.objects.filter(id__in=request.POST.getlist('object')) + + for netbox in netboxes: + url = request.build_absolute_uri( + reverse('ipdevinfo-details-by-id', kwargs={'netbox_id': netbox.id}) + ) + url_dict[str(netbox)] = url + + qr_codes_zip_file = generate_qr_codes_as_zip_file(url_dict=url_dict) + + info = NetboxInfo() + query = ( + Netbox.objects.select_related("room", "category", "type", "organization") + .prefetch_related("profiles") + .annotate(profile=ArrayAgg("profiles__name")) + ) + filter_form = NetboxFilterForm(request.GET) + value_list = ( + 'sysname', + 'room', + 'ip', + 'category', + 'organization', + 'profile', + 'type__name', + ) + return render_list( + request, + query, + value_list, + 'seeddb-netbox-edit', + edit_url_attr='pk', + filter_form=filter_form, + template='seeddb/list_netbox.html', + extra_context={ + **info.template_context, + **{"qr_codes_zip_file": qr_codes_zip_file}, + }, + ) + + def netbox_move(request): """Controller for handling a move request""" info = NetboxInfo() diff --git a/python/nav/web/seeddb/page/room.py b/python/nav/web/seeddb/page/room.py index a5624a3d48..fe063dba87 100644 --- a/python/nav/web/seeddb/page/room.py +++ b/python/nav/web/seeddb/page/room.py @@ -17,12 +17,14 @@ # """Forms and view functions for SeedDB's Room view""" +from django.http import HttpResponseRedirect from django.urls import reverse from nav.models.manage import Room from nav.bulkparse import RoomBulkParser from nav.bulkimport import RoomImporter +from nav.web.message import new_message, Messages from nav.web.seeddb import SeeddbInfo, reverse_lazy from nav.web.seeddb.constants import SEEDDB_EDITABLE_MODELS from nav.web.seeddb.page import view_switcher @@ -31,6 +33,10 @@ from nav.web.seeddb.utils.delete import render_delete from nav.web.seeddb.utils.move import move from nav.web.seeddb.utils.bulk import render_bulkimport +from nav.web.utils import ( + generate_qr_codes_as_byte_strings, + generate_qr_codes_as_zip_file, +) from ..forms import RoomForm, RoomFilterForm, RoomMoveForm @@ -56,7 +62,11 @@ class RoomInfo(SeeddbInfo): def room(request): """Controller for listing, moving and deleting rooms""" return view_switcher( - request, list_view=room_list, move_view=room_move, delete_view=room_delete + request, + list_view=room_list, + move_view=room_move, + delete_view=room_delete, + generate_qr_codes_view=room_generate_qr_codes, ) @@ -84,6 +94,43 @@ def room_move(request): ) +def room_generate_qr_codes(request): + """Controller for generating qr codes for rooms""" + if not request.POST.getlist('object'): + new_message( + request, + "You need to select at least one object to generate qr codes for", + Messages.ERROR, + ) + return HttpResponseRedirect(reverse('seeddb-room')) + + url_dict = dict() + ids = request.POST.getlist('object') + + for id in ids: + url = request.build_absolute_uri(reverse('room-info', kwargs={'roomid': id})) + url_dict[id] = url + + qr_codes_zip_file = generate_qr_codes_as_zip_file(url_dict=url_dict) + + info = RoomInfo() + value_list = ('id', 'location', 'description', 'position', 'data') + query = Room.objects.select_related("location").all() + filter_form = RoomFilterForm(request.GET) + # When we drop Python 3.7 we can use extra_context = info.template_context | {"qr_codes": qr_codes} + return render_list( + request, + query, + value_list, + 'seeddb-room-edit', + filter_form=filter_form, + extra_context={ + **info.template_context, + **{"qr_codes_zip_file": qr_codes_zip_file}, + }, + ) + + def room_delete(request, object_id=None): """Controller for deleting rooms. Used in room()""" info = RoomInfo() diff --git a/python/nav/web/templates/seeddb/list.html b/python/nav/web/templates/seeddb/list.html index 609dfca722..c90520bd78 100644 --- a/python/nav/web/templates/seeddb/list.html +++ b/python/nav/web/templates/seeddb/list.html @@ -30,6 +30,10 @@ {% endif %} + {% if qr_codes_zip_file %} + Download generated QR Codes + {% endif %} +
diff --git a/tests/integration/seeddb_test.py b/tests/integration/seeddb_test.py index bfd6b6bb97..de3e01d275 100644 --- a/tests/integration/seeddb_test.py +++ b/tests/integration/seeddb_test.py @@ -110,3 +110,71 @@ def test_log_netbox_change_should_not_crash(admin_account, netbox): new.category_id = "OTHER" assert log_netbox_change(admin_account, old, new) is None + + +def test_generating_qr_codes_for_netboxes_should_succeed(client, netbox): + url = reverse('seeddb-netbox') + + response = client.post( + url, + follow=True, + data={ + "qr_code": "Generate+QR+codes+for+selected", + "object": [netbox.id], + }, + ) + + assert response.status_code == 200 + assert 'Download generated QR Codes' in smart_str(response.content) + + +def test_generating_qr_codes_for_no_selected_netboxes_should_show_error(client, netbox): + url = reverse('seeddb-netbox') + + response = client.post( + url, + follow=True, + data={ + "qr_code": "Generate+QR+codes+for+selected", + }, + ) + + assert response.status_code == 200 + assert ( + 'You need to select at least one object to generate qr codes for' + in smart_str(response.content) + ) + + +def test_generating_qr_codes_for_rooms_should_succeed(client): + url = reverse('seeddb-room') + + response = client.post( + url, + follow=True, + data={ + "qr_code": "Generate+QR+codes+for+selected", + "object": ["myroom"], + }, + ) + + assert response.status_code == 200 + assert 'Download generated QR Codes' in smart_str(response.content) + + +def test_generating_qr_codes_for_no_selected_rooms_should_show_error(client, netbox): + url = reverse('seeddb-room') + + response = client.post( + url, + follow=True, + data={ + "qr_code": "Generate+QR+codes+for+selected", + }, + ) + + assert response.status_code == 200 + assert ( + 'You need to select at least one object to generate qr codes for' + in smart_str(response.content) + )