From aa156c1747093b66be58ccbb02134e1de9abb007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20J=C3=BClg?= <tobias.juelg@tum.de> Date: Wed, 25 May 2022 11:26:38 +0200 Subject: [PATCH] Adds QR Code Infiation for in Person Elections Adresses issue #30 and #45 --- management/consumers.py | 20 ++++++- management/routing.py | 1 + management/static/management/css/style.css | 7 +++ .../management/add_mobile_voter_name.html | 39 +++++++++++++ .../management/add_mobile_voter_qr.html | 48 ++++++++++++++++ management/templates/management/session.html | 2 + management/urls.py | 1 + management/views.py | 56 ++++++++++++++++++- vote/migrations/0031_voter_qr.py | 18 ++++++ vote/models.py | 12 ++-- vote/static/js/reload.js | 6 ++ vote/views.py | 10 ++++ 12 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 management/templates/management/add_mobile_voter_name.html create mode 100644 management/templates/management/add_mobile_voter_qr.html create mode 100644 vote/migrations/0031_voter_qr.py diff --git a/management/consumers.py b/management/consumers.py index 49530b0..d5807d5 100644 --- a/management/consumers.py +++ b/management/consumers.py @@ -6,7 +6,8 @@ from channels.generic.websocket import AsyncWebsocketConsumer class ElectionConsumer(AsyncWebsocketConsumer): async def connect(self): - self.group = "Election-" + self.scope['url_route']['kwargs']['pk'] # pylint: disable=W0201 + self.group = "Election-" + \ + self.scope['url_route']['kwargs']['pk'] # pylint: disable=W0201 await self.channel_layer.group_add(self.group, self.channel_name) await self.accept() @@ -48,3 +49,20 @@ class SessionConsumer(AsyncWebsocketConsumer): await self.send(text_data=json.dumps({ 'succ': event['msg'], })) + + +class AddMobileConsumer(AsyncWebsocketConsumer): + + async def connect(self): + self.group = "QR-Reload-" + \ + self.scope['url_route']['kwargs']['pk'] # pylint: disable=W0201 + await self.channel_layer.group_add(self.group, self.channel_name) + await self.accept() + + async def disconnect(self, code): + await self.channel_layer.group_discard(self.group, self.channel_name) + + async def send_reload(self, event): + await self.send(text_data=json.dumps({ + 'open': event['link'], + })) diff --git a/management/routing.py b/management/routing.py index 51d0f8c..9c27d43 100644 --- a/management/routing.py +++ b/management/routing.py @@ -5,4 +5,5 @@ from . import consumers websocket_urlpatterns = URLRouter([ re_path(r'election/(?P<pk>\d+)$', consumers.ElectionConsumer.as_asgi()), re_path(r'meeting/(?P<pk>\d+)$', consumers.SessionConsumer.as_asgi()), + re_path(r'meeting/(?P<pk>\d+)/add_mobile_voter$', consumers.AddMobileConsumer.as_asgi()), ]) diff --git a/management/static/management/css/style.css b/management/static/management/css/style.css index d3bc3b8..07b8521 100644 --- a/management/static/management/css/style.css +++ b/management/static/management/css/style.css @@ -103,3 +103,10 @@ th.monospace { content: ""; pointer-events: none; } + + .centerimg { + display: block; + margin-left: auto; + margin-right: auto; + width: 100%; +} \ No newline at end of file diff --git a/management/templates/management/add_mobile_voter_name.html b/management/templates/management/add_mobile_voter_name.html new file mode 100644 index 0000000..e6613cb --- /dev/null +++ b/management/templates/management/add_mobile_voter_name.html @@ -0,0 +1,39 @@ +{% extends 'management/base.html' %} +{% load static %} +{% load crispy_forms_filters %} + +{% block content %} +<div class="row justify-content-center"> + <div class="col-12"> + <div class="card shadow"> + <div class="card-body"> + <h4>Add Voters via QR Code</h4> + <span>What's the voters name? Hint: You can also leave the name empty. + </span> + <hr> + <form class="form-group" action="{% url 'management:add_mobile_voter' session.pk %}" method="post"> + {% csrf_token %} + <label for="name">Voter's name:</label> + <input type="text" id="name" name="name" class="form-control"><br> + <button type="submit" id="id_btn_submit" class="btn btn-primary btn-block">Submit</button> + </form> + <a class="btn btn-secondary btn-block" href="{% url 'management:session' session.pk %}">Cancel</a> + </div> + </div> + </div> +</div> +{% endblock %} +{% block footer_scripts %} +{# Automatic reload of the page: #} +{# - either if the start date / end date of a election is due#} +{# - or if the admin started / stopped one election and the page is notified with a websocket#} +{# - or if a voter has logged in #} +<script src="{% static " js/jquery-3.5.1.min.js" %}" + integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="></script> +<script src="{% static " js/reload.js" %}"></script> + +<script src="{% static " js/popper-1.16.1.min.js" %}" + integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN"></script> +<script src="{% static " bootstrap-4.5.3-dist/js/bootstrap.min.js" %}" + integrity="sha384-w1Q4orYjBQndcko6MimVbzY0tgp4pWB4lZ7lr30WKz0vr/aWKhXdBNmNb5D92v7s"></script> +{% endblock %} \ No newline at end of file diff --git a/management/templates/management/add_mobile_voter_qr.html b/management/templates/management/add_mobile_voter_qr.html new file mode 100644 index 0000000..f5640fe --- /dev/null +++ b/management/templates/management/add_mobile_voter_qr.html @@ -0,0 +1,48 @@ +{% extends 'management/base.html' %} +{% load static %} +{% load crispy_forms_filters %} + +{% block content %} +<div id="qr-img"> + <div class="row justify-content-center"> + <div class="col-12"> + <div class="card shadow"> + <div class="card-body"> + <h4>Add Voters via QR Code</h4> + <span>Show the following QR code to the people you want to add to your election session. + </span> + <hr> + <h5 class="text-center">{{ name }}</h5> + <img src="data:image/png;base64,{{ qr }}" alt="QR Code" class="centerimg"> + <hr> + <span>The page refreshes automatically if it is scanned. However, you can also request a + new one manually if somebody has a bad Internet connection. + </span> + <br><br> + <a class="btn btn-primary btn-block" href="{% url 'management:add_mobile_voter' session.pk %}">Add New</a> + <br> + <form class="user" action="{% url 'management:add_mobile_voter' session.pk %}" method="post"> + {% csrf_token %} + <input type="hidden" name="cancel" value="{{ voter }}"> + <button type="submit" id="id_btn_start" class="btn btn-secondary btn-block">Cancel</button> + </form> + </div> + </div> + </div> + </div> +</div> +{% endblock %} +{% block footer_scripts %} +{# Automatic reload of the page: #} +{# - either if the start date / end date of a election is due#} +{# - or if the admin started / stopped one election and the page is notified with a websocket#} +{# - or if a voter has logged in #} +<script src="{% static " js/jquery-3.5.1.min.js" %}" + integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="></script> +<script src="{% static " js/reload.js" %}"></script> + +<script src="{% static " js/popper-1.16.1.min.js" %}" + integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN"></script> +<script src="{% static " bootstrap-4.5.3-dist/js/bootstrap.min.js" %}" + integrity="sha384-w1Q4orYjBQndcko6MimVbzY0tgp4pWB4lZ7lr30WKz0vr/aWKhXdBNmNb5D92v7s"></script> +{% endblock %} \ No newline at end of file diff --git a/management/templates/management/session.html b/management/templates/management/session.html index d9b2c88..b19eefd 100644 --- a/management/templates/management/session.html +++ b/management/templates/management/session.html @@ -88,6 +88,8 @@ href="{% url 'management:import_csv' pk=session.pk %}">Import from CSV</a> <a class="dropdown-item" href="{% url 'management:add_tokens' pk=session.pk %}">Add Tokens</a> + <a class="dropdown-item" + href="{% url 'management:add_mobile_voter' pk=session.pk %}">QR-Codes</a> <button type="button" class="dropdown-item" data-toggle="modal" data-target="#downloadToken" aria-label="download tokens"> diff --git a/management/urls.py b/management/urls.py index 1a5779b..2a0894e 100644 --- a/management/urls.py +++ b/management/urls.py @@ -28,6 +28,7 @@ urlpatterns = [ path('meeting/<int:pk>/delete_session', views.delete_session, name='delete_session'), path('meeting/<int:pk>/add_voters', views.add_voters, name='add_voters'), path('meeting/<int:pk>/add_tokens', views.add_tokens, name='add_tokens'), + path('meeting/<int:pk>/add_mobile_voter', views.add_mobile_voter_get, name='add_mobile_voter'), path('meeting/<int:pk>/add_election', views.add_election, name='add_election'), path('meeting/<int:pk>/print_token', views.print_token, name='print_token'), path('meeting/<int:pk>/import_csv', views.import_csv, name='import_csv'), diff --git a/management/views.py b/management/views.py index 655e2f7..651ebd9 100644 --- a/management/views.py +++ b/management/views.py @@ -1,4 +1,6 @@ +import base64 import csv +from io import BytesIO import logging import os from argparse import Namespace @@ -346,6 +348,58 @@ def delete_session(request, pk): return redirect('management:index') +@management_login_required +def add_mobile_voter_get(request, pk): + if request.method == "POST": + return add_mobile_voter_post(request, pk) + + manager = request.user + session = manager.sessions.filter(pk=pk) + if not session.exists(): + return HttpResponseNotFound('Session does not exist') + session = session.first() + + context = { + 'session': session, + } + return render(request, template_name='management/add_mobile_voter_name.html', context=context) + + +@csrf_protect +def add_mobile_voter_post(request, pk): + manager = request.user + session = manager.sessions.filter(pk=pk) + if not session.exists(): + return HttpResponseNotFound('Session does not exist') + session = session.first() + + if request.POST.get("cancel"): + # delete the just created voter if manager cancels + voter = session.participants.filter(pk=int(request.POST.get("cancel"))) + if not voter.exists(): + messages.add_message(request, messages.ERROR, + 'Error: Could not delete QR code participant!') + else: + voter.delete() + return redirect('management:session', pk=session.pk) + + name = request.POST.get("name") + voter, access_code = Voter.from_data(session=session, qr=True, name=name) + link = f'https://{settings.URL}' + reverse('vote:link_login', kwargs={'access_code': access_code}) + img = qrcode.make(link) + + buffered = BytesIO() + img.save(buffered, "PNG") + context = { + 'session': session, + 'qr': base64.b64encode(buffered.getvalue()).decode('utf-8'), + 'voter': voter.pk, + 'name': name, + 'link': link, + } + return render(request, template_name='management/add_mobile_voter_qr.html', context=context) + + @management_login_required def print_token(request, pk): session = request.user.sessions.filter(pk=pk) @@ -354,7 +408,7 @@ def print_token(request, pk): session = session.first() participants = session.participants tokens = [participant.new_access_token() - for participant in participants.all() if participant.is_anonymous] + for participant in participants.all() if participant.is_anonymous and not participant.qr] if len(tokens) == 0: messages.add_message(request, messages.ERROR, 'No tokens have yet been generated.') diff --git a/vote/migrations/0031_voter_qr.py b/vote/migrations/0031_voter_qr.py new file mode 100644 index 0000000..6e9d23a --- /dev/null +++ b/vote/migrations/0031_voter_qr.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2022-05-20 21:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0030_auto_20220508_1226'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='qr', + field=models.BooleanField(default=False), + ), + ] diff --git a/vote/models.py b/vote/models.py index eb34cde..1481aa8 100644 --- a/vote/models.py +++ b/vote/models.py @@ -183,6 +183,7 @@ class Voter(models.Model): invalid_email = models.BooleanField(default=False) session = models.ForeignKey(Session, related_name='participants', on_delete=models.CASCADE) logged_in = models.BooleanField(default=False) + qr = models.BooleanField(default=False) name = models.CharField(max_length=256, blank=True, null=True) # Stores the raw password if set_password() is called so that it can @@ -195,11 +196,13 @@ class Voter(models.Model): unique_together = ('session', 'email') def __str__(self): - if self.email is None: + if self.email: + return self.email + elif self.name: + return self.name + else: return f'anonymous-{self.pk}' - return self.email - def save(self, force_insert=False, force_update=False, using=None, update_fields=None): if update_fields == ['last_login']: @@ -426,11 +429,12 @@ class Voter(models.Model): return voter_id, password @classmethod - def from_data(cls, session, email=None, name=None) -> Tuple['Voter', str]: + def from_data(cls, session, email=None, name=None, qr=False) -> Tuple['Voter', str]: voter = Voter( session=session, email=email, name=name, + qr=qr, ) password = voter.set_password() voter.save() diff --git a/vote/static/js/reload.js b/vote/static/js/reload.js index 34526a9..8d938b8 100644 --- a/vote/static/js/reload.js +++ b/vote/static/js/reload.js @@ -5,6 +5,10 @@ $(document).ready(() => { setup_date_reload(); } + function open(link){ + window.open(link,"_self") + } + function reload(reload_id="#content") { console.log("Reloading " + reload_id) $(reload_id).load(location.pathname + " " + reload_id, reload_callback) @@ -41,6 +45,8 @@ $(document).ready(() => { let succ_div = $('#message-success'); succ_div.find('div').html(message.succ); succ_div.toggleClass('hide'); + }else if(message.open){ + open(message.open); } } ws.onopen = function (e) { diff --git a/vote/views.py b/vote/views.py index a4f7869..275bb0b 100644 --- a/vote/views.py +++ b/vote/views.py @@ -5,8 +5,11 @@ from django.contrib import messages from django.contrib.auth import authenticate, login, views as auth_views from django.http.response import HttpResponseNotFound from django.shortcuts import render, redirect, get_object_or_404 +from django.urls import reverse from django.utils.decorators import method_decorator from ratelimit.decorators import ratelimit +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from vote.authentication import voter_login_required from vote.forms import AccessCodeAuthenticationForm, VoteForm, ApplicationUploadFormUser @@ -53,6 +56,13 @@ def code_login(request, access_code=None): login(request, user) + if user.qr: + group = "QR-Reload-" + str(user.session.pk) + async_to_sync(get_channel_layer().group_send)( + group, + {'type': 'send_reload', 'link': reverse('management:add_mobile_voter', args=[user.session.pk])} + ) + return redirect('vote:index') -- GitLab