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