Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it possible to download a ZIP file containing QR codes of bulk selected netboxes/rooms #2899

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/+qr-code-bulk.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add button to SeedDB that generates a ZIP file to download with QR Codes linking to the selected netboxes/rooms
2 changes: 2 additions & 0 deletions python/nav/web/seeddb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion python/nav/web/seeddb/page/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
66 changes: 65 additions & 1 deletion python/nav/web/seeddb/page/netbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -48,12 +55,17 @@ 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):
"""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,
)


Expand Down Expand Up @@ -111,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()
Expand Down
50 changes: 49 additions & 1 deletion python/nav/web/seeddb/page/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -50,12 +56,17 @@ 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):
"""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,
)


Expand Down Expand Up @@ -83,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()
Expand Down
9 changes: 8 additions & 1 deletion python/nav/web/templates/seeddb/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,24 @@
{% endif %}

<form action="{{ request.path }}" method="post">
{% if not hide_move or not hide_delete %}
{% if not hide_move or not hide_delete or not hide_qr_code%}
<div>
{% if not hide_move %}
<input type="submit" name="move" value="Move selected" class="button small secondary" />
{% endif %}
{% if not hide_delete %}
<input type="submit" name="delete" value="Delete selected" class="button small secondary"/>
{% endif %}
{% if not hide_qr_code %}
<input type="submit" name="qr_code" value="Generate QR codes for selected" class="button small secondary"/>
{% endif %}
</div>
{% endif %}

{% if qr_codes_zip_file %}
<a src="{{ qr_codes_zip_file }}" download="nav_qr_codes">Download generated QR Codes</a>
{% endif %}

<div id="tablewrapper" class="notvisible" data-forpage="{{ request.path }}" data-page="{{ active_page }}">
<table id="seeddb-content" class="listtable" width="100%">
<caption>
Expand Down
26 changes: 26 additions & 0 deletions python/nav/web/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
68 changes: 68 additions & 0 deletions tests/integration/seeddb_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Loading