diff --git a/management/consumers.py b/management/consumers.py index 49530b092ba7d10b50cbfe1a40199fb74ec63f6b..d5807d575081147777a44cd8f9e67b846e2bb322 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 51d0f8c2ccf77381eeee9b68be845b747301adf9..9c27d43c331fe9c08b13a4e118402ebf6c356886 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 d3bc3b88731195a6ed87d7d43830b1391715881f..07b852167811f1236d76cfcad5a4cb6add087ce8 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 0000000000000000000000000000000000000000..7f66e1953b0a3d9eb1f244df1bcd27d171f970ac --- /dev/null +++ b/management/templates/management/add_mobile_voter_name.html @@ -0,0 +1,34 @@ +{% 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 %} + <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 "bootstrap-4.5.3-dist/js/bootstrap.min.js" %}" + integrity="sha384-w1Q4orYjBQndcko6MimVbzY0tgp4pWB4lZ7lr30WKz0vr/aWKhXdBNmNb5D92v7s"></script> + <script src="{% static "management/js/session.js" %}"></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 0000000000000000000000000000000000000000..60114f611204d7ee0df4eb9f2fb5a72e6035bb58 --- /dev/null +++ b/management/templates/management/add_mobile_voter_qr.html @@ -0,0 +1,45 @@ +{% 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 %} +{# If the QR code was scanned and the user logged in successfully the page will automatically #} +{# change to add_mobile_voter_name again #} + <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 "bootstrap-4.5.3-dist/js/bootstrap.min.js" %}" + integrity="sha384-w1Q4orYjBQndcko6MimVbzY0tgp4pWB4lZ7lr30WKz0vr/aWKhXdBNmNb5D92v7s"></script> + <script src="{% static "management/js/session.js" %}"></script> +{% endblock %} \ No newline at end of file diff --git a/management/templates/management/index.html b/management/templates/management/index.html index f92fe2d03987223336cdf9b8be48c97c4abb593a..8152fc88fecfa3ee21003c14422a5975095ff77c 100644 --- a/management/templates/management/index.html +++ b/management/templates/management/index.html @@ -12,6 +12,11 @@ </div> <div class="card-body"> <div class="list-group"> + {% if len_sessions == 0 %} + <div class="list-group-item mt-3"> + <span>You have no sessions currently.</span> + </div> + {% endif %} {% for session in sessions %} <div class="list-group-item list-group-item-action"> <a class="main-link" href="{% url 'management:session' session.id %}"></a> diff --git a/management/templates/management/session.html b/management/templates/management/session.html index d9b2c88ab5d9b8f8ffc9882cda67ad3337bd3235..b19eefdb632bc144a910b4a73afeb7cde7549e7f 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 1a5779b25327b884487f5baedef5f43b9400358b..2a0894e44ab771c1056d7a3614ae7915089ebd12 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 795a8e0efd79c78f6f86537fa091cd63ca73bb05..8b1986af482f09b11754ec99821d30ce497edf0f 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 @@ -84,7 +86,8 @@ def index(request): context={'form': form, 'variables': form.variables}) context = { - 'sessions': manager.sessions.order_by('-pk') + 'sessions': manager.sessions.order_by('-pk'), + 'len_sessions': manager.sessions.count(), } return render(request, template_name='management/index.html', context=context) @@ -345,6 +348,61 @@ def delete_session(request, pk): return redirect('management:index') +@management_login_required +def add_mobile_voter_get(request, pk): + if request.method == "POST": + # if we get a post we will create a voter and show the qr code + return add_mobile_voter_post(request, pk) + + # for get we show the page where the user has to type the voter's name + + 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) @@ -353,7 +411,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 0000000000000000000000000000000000000000..6e9d23ab11dc637877b9ceb8d0759bd69f2fc0f7 --- /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 eb34cde60bb28f28b8c01e4c2d333b2ad9948924..c927c9320bc1adcf100d3378579af72288a39ee1 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,10 +196,11 @@ class Voter(models.Model): unique_together = ('session', 'email') def __str__(self): - if self.email is None: - return f'anonymous-{self.pk}' - - return self.email + if self.email: + return self.email + if self.name: + return self.name + return f'anonymous-{self.pk}' def save(self, force_insert=False, force_update=False, using=None, update_fields=None): @@ -426,11 +428,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 34526a90255000ece1816a71368d516b4a03d644..8d938b8a8c47c724e9af07a65901c4ea3c5af2b7 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 a4f78698e3bf73cc02af948f0a6cf780562a41db..275bb0bc8075022b52477d293ec43f149af808b0 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') diff --git a/wahlfang/__init__.py b/wahlfang/__init__.py index 9613b39b18dfb3a0bf55cf4581003bdca0f5753c..da2182f16c818d619986617ee299b3db5d22c183 100644 --- a/wahlfang/__init__.py +++ b/wahlfang/__init__.py @@ -1 +1 @@ -__version__ = '1.0.5.1' +__version__ = '1.0.6'