Implement proxy voting in meeting-votes
authorMagnus Hagander <magnus@hagander.net>
Thu, 17 May 2018 19:42:16 +0000 (21:42 +0200)
committerMagnus Hagander <magnus@hagander.net>
Fri, 18 May 2018 11:41:34 +0000 (13:41 +0200)
A proxy voter is given a name and a unique proxy voting key. This voting
key (generated the same way as our other "secure tokens") can be used to
access the voting for this member, and then return the same access to
the meeting.

A proxy voter can be assigned up to 4 hours before the meeting, this
being the same time as when a regular member can log in to access their
meetings.

Including review/fixes from Dave

postgresqleu/membership/forms.py
postgresqleu/membership/migrations/0003_proxy_voting.py [new file with mode: 0644]
postgresqleu/membership/models.py
postgresqleu/membership/views.py
postgresqleu/urls.py
template/membership/meetingproxy.html [new file with mode: 0644]
template/membership/meetings.html

index 9814c9283238738a9a0edeaf3a30b8c6422bc927..aa097c1cf29c1a9ad1e002082a89c10d8711846f 100644 (file)
@@ -19,3 +19,6 @@ class MemberForm(forms.ModelForm):
                        if isinstance(msg, str):
                                raise ValidationError(msg)
                return self.cleaned_data['country']
+
+class ProxyVoterForm(forms.Form):
+       name = forms.CharField(min_length=5, max_length=100, help_text="Name of proxy voter. Leave empty to cancel proxy voting.", required=False)
diff --git a/postgresqleu/membership/migrations/0003_proxy_voting.py b/postgresqleu/membership/migrations/0003_proxy_voting.py
new file mode 100644 (file)
index 0000000..834db0f
--- /dev/null
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('membership', '0002_member_country_exception'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='membermeetingkey',
+            name='proxyaccesskey',
+            field=models.CharField(max_length=100, null=True),
+        ),
+        migrations.AddField(
+            model_name='membermeetingkey',
+            name='proxyname',
+            field=models.CharField(max_length=200, null=True),
+        ),
+    ]
index 9ab0abd16902e0b7ecc1fa3f230ead19f820dd37..66e7eeb83549823617b17caf2e2ca66def0c8a3f 100644 (file)
@@ -66,10 +66,19 @@ class Meeting(models.Model):
                        return True
                return False
 
+       def get_key_for(self, member):
+               try:
+                       return MemberMeetingKey.objects.get(meeting=self, member=member)
+               except MemberMeetingKey.DoesNotExist:
+                       return None
+
+
 class MemberMeetingKey(models.Model):
        member = models.ForeignKey(Member, null=False, blank=False)
        meeting = models.ForeignKey(Meeting, null=False, blank=False)
        key = models.CharField(max_length=100, null=False, blank=False)
+       proxyname = models.CharField(max_length=200, null=True, blank=False)
+       proxyaccesskey = models.CharField(max_length=100, null=True, blank=False)
 
        class Meta:
                unique_together = (('member', 'meeting'), )
index 492fa2bc53805669e8283113885e680485046629..1181b865fa9434fc68348ca08574f33e94279865 100644 (file)
@@ -7,9 +7,10 @@ from django.db import transaction
 from django.db.models import Q
 
 from models import Member, MemberLog, Meeting, MemberMeetingKey
-from forms import MemberForm
+from forms import MemberForm, ProxyVoterForm
 
 from postgresqleu.util.decorators import user_passes_test_or_error
+from postgresqleu.util.random import generate_random_token
 from postgresqleu.invoices.util import InvoiceManager, InvoicePresentationWrapper
 from postgresqleu.invoices.models import InvoiceProcessor
 from postgresqleu.confreg.forms import EmailSendForm
@@ -148,19 +149,22 @@ def meetings(request):
        q = Q(dateandtime__gte=datetime.now()-timedelta(hours=4)) & (Q(allmembers=True) | Q(members=member))
        meetings = Meeting.objects.filter(q).order_by('dateandtime')
 
+       meetinginfo = [{
+               'id': m.id,
+               'name': m.name,
+               'joining_active': m.joining_active,
+               'dateandtime': m.dateandtime,
+               'key': m.get_key_for(member),
+               } for m in meetings]
+
        return render(request, 'membership/meetings.html', {
                'active': member.paiduntil and member.paiduntil >= datetime.today().date(),
                'member': member,
-               'meetings': meetings,
+               'meetings': meetinginfo,
                })
 
-@login_required
 @transaction.atomic
-def meeting(request, meetingid):
-       # View a single meeting
-       meeting = get_object_or_404(Meeting, pk=meetingid)
-       member = get_object_or_404(Member, user=request.user)
-
+def _meeting(request, member, meeting, isproxy):
        if not (member.paiduntil and member.paiduntil >= datetime.today().date()):
                return HttpResponse("Your membership is not active")
 
@@ -185,12 +189,85 @@ def meeting(request, meetingid):
                key.key = base64.urlsafe_b64encode(os.urandom(40)).rstrip('=')
                key.save()
 
+       if key.proxyname and not isproxy:
+               return HttpResponse(u"You have assigned a proxy attendee for this meeting ({0}). This means you cannot attend the meeting yourself.".format(key.proxyname))
+
        return render(request, 'membership/meeting.html', {
                'member': member,
                'meeting': meeting,
                'key': key,
                })
 
+@login_required
+def meeting(request, meetingid):
+       # View a single meeting
+       meeting = get_object_or_404(Meeting, pk=meetingid)
+       member = get_object_or_404(Member, user=request.user)
+       return _meeting(request, member, meeting, False)
+
+def meeting_by_key(request, meetingid, token):
+       key = get_object_or_404(MemberMeetingKey, proxyaccesskey=token)
+       return _meeting(request, key.member, key.meeting, True)
+
+@login_required
+@transaction.atomic
+def meeting_proxy(request, meetingid):
+       # Assign proxy voter for meeting
+       meeting = get_object_or_404(Meeting, pk=meetingid)
+       member = get_object_or_404(Member, user=request.user)
+
+       if not (member.paiduntil and member.paiduntil >= datetime.today().date()):
+               return HttpResponse("Your membership is not active")
+
+       if not meeting.allmembers:
+               if not meeting.members.filter(pk=member.pk).exists():
+                       return HttpResponse("Access denied.")
+
+       if member.paiduntil < meeting.dateandtime.date():
+               return HttpResponse("Your membership expires before the meeting")
+
+       # Do we have one already?
+       try:
+               key = MemberMeetingKey.objects.get(member=member, meeting=meeting)
+       except MemberMeetingKey.DoesNotExist:
+               key = MemberMeetingKey()
+               key.meeting = meeting
+               key.member = member
+
+       initial = {
+               'name': key.proxyname,
+       }
+
+       if request.method == 'POST':
+               form = ProxyVoterForm(initial=initial, data=request.POST)
+               if form.is_valid():
+                       if form.cleaned_data['name']:
+                               key.proxyname = form.cleaned_data['name']
+                               key.proxyaccesskey = generate_random_token()
+                               key.key = base64.urlsafe_b64encode(os.urandom(40)).rstrip('=')
+                               key.save()
+                               MemberLog(member=member,
+                                                 timestamp=datetime.now(),
+                                                 message=u"Assigned {0} as proxy voter in {1}".format(key.proxyname, meeting.name)
+                               ).save()
+                               return HttpResponseRedirect('.')
+                       else:
+                               key.delete()
+                               MemberLog(member=member,
+                                                 timestamp=datetime.now(),
+                                                 message=u"Canceled proxy voting in {0}".format(meeting.name)
+                               ).save()
+                               return HttpResponseRedirect("../../")
+       else:
+               form = ProxyVoterForm(initial=initial)
+
+       return render(request, 'membership/meetingproxy.html', {
+               'member': member,
+               'meeting': meeting,
+               'key': key,
+               'form': form,
+               })
+
 # API calls from meeting bot
 def meetingcode(request):
        secret = request.GET['s']
@@ -198,13 +275,21 @@ def meetingcode(request):
 
        try:
                key = MemberMeetingKey.objects.get(key=secret, meeting__pk=meetingid)
+               if key.meeting.dateandtime + timedelta(hours=4) < datetime.now():
+                       return HttpResponse(jsuon.dumps({'err': 'This meeting is not open for signing in yet.'}),
+                                                               content_type='application/json')
                member = key.member
        except MemberMeetingKey.DoesNotExist:
                return HttpResponse(json.dumps({'err': 'Authentication key not found. Please see %s/membership/meetings/ to get your correct key!' % settings.SITEBASE}),
                                                        content_type='application/json')
 
+       if key.proxyname:
+               name = u"{0} (proxy for {1})".format(key.proxyname, member.fullname)
+       else:
+               name = member.fullname
+
        # Return a JSON object with information about the member
        return HttpResponse(json.dumps({'username': member.user.username,
                                                                                  'email': member.user.email,
-                                                                                 'name': member.fullname,
+                                                                                 'name': name,
                                                                          }), content_type='application/json')
index bf20b0630143e0e786c2e45739f166f5ac7b22bb..1a824426815c8d7b991ee0af93c19e831acba34d 100644 (file)
@@ -178,6 +178,8 @@ urlpatterns = [
        url(r'^membership/$', postgresqleu.membership.views.home),
        url(r'^membership/meetings/$', postgresqleu.membership.views.meetings),
        url(r'^membership/meetings/(\d+)/$', postgresqleu.membership.views.meeting),
+       url(r'^membership/meetings/(\d+)/([a-z0-9]{64})/$', postgresqleu.membership.views.meeting_by_key),
+       url(r'^membership/meetings/(\d+)/proxy/$', postgresqleu.membership.views.meeting_proxy),
        url(r'^membership/meetingcode/$', postgresqleu.membership.views.meetingcode),
        url(r'^community/members/$', postgresqleu.membership.views.userlist),
        url(r'^admin/membership/_email/$', postgresqleu.membership.views.admin_email),
diff --git a/template/membership/meetingproxy.html b/template/membership/meetingproxy.html
new file mode 100644 (file)
index 0000000..192e78e
--- /dev/null
@@ -0,0 +1,41 @@
+{%extends "nav_membership.html"%}
+{%block title%}Proxy voting{%endblock%}
+{%block content%}
+<h1>Proxy voting</h1>
+
+{%if key.proxyname %}
+<h2>Proxy voter</h2>
+<p>
+  You are currently set up to allow {{key.proxyname}} to attend and vote on your
+  behalf in {{meeting.name}}.
+</p>
+<p>
+  For them to retreive the instructions for connecting, plase ask them to visit
+  the page at
+  <a href="/membership/meetings/{{meeting.id}}/{{key.proxyaccesskey}}/">{{sitebase}}/membership/meetings/{{meeting.id}}/{{key.proxyaccesskey}}/</a>
+  within 4 hours before the meeting.
+</p>
+{%else%}
+<p>
+  On this form you can assign a proxy voter. This person will be able to attend
+  and vote on your behalf in {{meeting.name}}.
+</p>
+{%endif%}
+
+<h2>New proxy voter</h2>
+{%if form.errors%}
+<p>
+<b>NOTE!</b> Your submitted form contained errors and has <b>not</b> been saved!
+</p>
+{%endif%}
+
+<form method="post" action=".">{% csrf_token %}
+<table>
+{{form}}
+</table>
+
+<input type="submit" value="Save">
+</form>
+
+<p><a href="../../">Return</a> to list of meetings</p>
+{%endblock%}
index 1e63ec2b8d205fa1331a35374c79c2252e4c001d..e8984ca7d4e0eb065d30956f607dda1127431097 100644 (file)
@@ -17,7 +17,11 @@ already generated), and then follow the instructions on that page.
 </p>
 <ul>
 {%for m in meetings%}
- <li>{%if m.joining_active%}<a href="{{m.id}}/">{{m.name}}</a>{%else%}{{m.name}} (not open yet){%endif%} ({{m.dateandtime}})</li>
+ <li>{%if m.joining_active%}
+   <a href="{{m.id}}/">{{m.name}}</a> ({%if m.key%}Edit{%else%}Assign{%endif%} <a href="{{m.id}}/proxy/">proxy</a> voter)
+   {%else%}
+   {{m.name}} (not open yet. {%if m.key%}Edit{%else%}Assign{%endif%} <a href="{{m.id}}/proxy/">proxy</a> voter)
+   {%endif%} ({{m.dateandtime}})</li>
 {%endfor%}
 </ul>
 {%else%}