diff --git a/.gitignore b/.gitignore index bd57f96cdfe95a3b0a40bcebc52c32d440be31ed..e061740b3733c98a4c82aa903a452dd912b15dda 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.pyc .idea *.sqlite3 +*.log /media /build /dist diff --git a/management/forms.py b/management/forms.py index 8fd739f85868384959ba86617dcdda4264ff05a6..6f22792351f3d594ed26927515affe5987d029d3 100644 --- a/management/forms.py +++ b/management/forms.py @@ -208,7 +208,7 @@ class AddElectionForm(forms.ModelForm, TemplateStringForm): model = Election fields = ( 'title', 'start_date', 'end_date', 'session', 'max_votes_yes', 'voters_self_apply', 'send_emails_on_start', - 'remind_text', 'disable_abstention', 'result_unpublished') + 'remind_text', 'enable_abstention', 'result_published') labels = { 'title': 'Election Name', @@ -217,10 +217,10 @@ class AddElectionForm(forms.ModelForm, TemplateStringForm): 'voters_self_apply': 'Voters can apply for the election', 'send_emails_on_start': 'Voters receive an e-mail when the election starts<br>' '(useful for elections that last several days)', - 'disable_abstention': 'Disable the option to abstain in this election<br>' - '(only YES and NO votes will be allowed)', + 'enable_abstention': 'Enable the option to abstain in this election<br>' + '(YES, NO and ABSTENTION votes will be allowed)', 'remind_text': '', - 'result_unpublished': 'Disable auto publish of the election results', + 'result_published': 'Automatically publish the election results', } def clean_remind_text(self): diff --git a/management/management/commands/create_admin.py b/management/management/commands/create_admin.py index 291ef6d6e3cc9a4f7fe29e84a816fe527934d5b7..605a8efa5a1b64cafd819fba925b7968015caf93 100644 --- a/management/management/commands/create_admin.py +++ b/management/management/commands/create_admin.py @@ -60,4 +60,4 @@ class Command(BaseCommand): manager.save() self.stdout.write(self.style.SUCCESS( - f'Successfully created management login with username {username}, email {email}, password: {password}')) + f'Successfully created management login with username: {username}, email: {email}')) diff --git a/management/templates/management/add_election.html b/management/templates/management/add_election.html index 17873c02ad45da25c5813fd0412eea4e2636c169..47df2a622b868889f5de3b7c03f85f1aaf4d0666 100644 --- a/management/templates/management/add_election.html +++ b/management/templates/management/add_election.html @@ -17,7 +17,7 @@ {{ form|as_crispy_errors }} {% for field in form %} - {% if field.html_name != "remind_text" and field.html_name != "send_emails_on_start" and field.html_name != "voters_self_apply" and field.html_name != "email" and field.html_name != 'disable_abstention' and field.html_name != 'result_unpublished' %} + {% if field.html_name != "remind_text" and field.html_name != "send_emails_on_start" and field.html_name != "voters_self_apply" and field.html_name != "email" and field.html_name != 'enable_abstention' and field.html_name != 'result_published' %} {{ field|as_crispy_field }} {% endif %} {% endfor %} @@ -32,8 +32,8 @@ </span> </div> <div id="collapseOne" class="card-body collapse{% if form.remind_text.value %} show{% endif %}"> - {{ form.result_unpublished|as_crispy_field }} - {{ form.disable_abstention|as_crispy_field }} + {{ form.result_published|as_crispy_field }} + {{ form.enable_abstention|as_crispy_field }} {{ form.voters_self_apply|as_crispy_field }} {{ form.send_emails_on_start|as_crispy_field }} <h5>Remind email template text</h5> diff --git a/management/templates/management/election.html b/management/templates/management/election.html index aba5e8ceac651de29089543f9e8b20f05f945a47..471f5c780fd57f764e5b67544eafec2706ab3717 100644 --- a/management/templates/management/election.html +++ b/management/templates/management/election.html @@ -120,7 +120,7 @@ <h4>Result</h4> {% include 'management/results.html' %} - {% if election.result_unpublished %} + {% if not election.result_published %} <hr> <form action="{% url 'management:election' election.pk %}" method="post"> {% csrf_token %} diff --git a/management/views.py b/management/views.py index d2592d611e9f6017adfa1c56c8aeb119cb9ba223..475dede475d58c9537580ccbe130ab36f2e64c9a 100644 --- a/management/views.py +++ b/management/views.py @@ -34,6 +34,7 @@ from management.forms import ( SessionSettingsForm ) from vote.models import Election, Application, Voter +from vote.selectors import open_elections, upcoming_elections, published_elections, closed_elections logger = logging.getLogger('management.view') @@ -90,19 +91,13 @@ def index(request): def session_detail(request, pk=None): manager = request.user session = manager.sessions.get(id=pk) - elections = session.elections.order_by('pk') - existing_elections = bool(elections) - open_elections = [e for e in elections if e.is_open] - upcoming_elections = [e for e in elections if not e.started] - published_elections = [e for e in elections if e.closed and not e.result_unpublished] - closed_elections = [e for e in elections if e.closed and e.result_unpublished] context = { 'session': session, - 'existing_elections': existing_elections, - 'open_elections': open_elections, - 'upcoming_elections': upcoming_elections, - 'published_elections': published_elections, - 'closed_elections': closed_elections, + 'existing_elections': bool(session.elections), + 'open_elections': open_elections(session), + 'upcoming_elections': upcoming_elections(session), + 'published_elections': published_elections(session), + 'closed_elections': closed_elections(session), 'voters': session.participants.all() } return render(request, template_name='management/session.html', context=context) @@ -248,7 +243,7 @@ def election_detail(request, pk): context['start_election_form'] = form if request.POST and request.POST.get('action') == 'publish': - election.result_unpublished = False + election.result_published = True election.save() return render(request, template_name='management/election.html', context=context) diff --git a/requirements_dev.txt b/requirements_dev.txt index 7d536fbc518a5b4aaded10235b82170a8613e891..34bfbce35a15d20f72f07b8d0d5533d82f39863f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,3 +3,4 @@ pylint~=2.8 pylint-django~=2.4 django-stubs build +freezegun \ No newline at end of file diff --git a/vote/forms.py b/vote/forms.py index 1c3f878265bb8895c94c4ca8df69e73c176f99de..0ec5006f42fd431c34731a9af31e399796c8a45e 100644 --- a/vote/forms.py +++ b/vote/forms.py @@ -61,12 +61,12 @@ class VoteBoundField(forms.BoundField): class VoteField(forms.ChoiceField): - def __init__(self, *, application, disable_abstention=False, **kwargs): + def __init__(self, *, application, enable_abstention=True, **kwargs): super().__init__( label=application.get_display_name(), - choices=VOTE_CHOICES_NO_ABSTENTION if disable_abstention else VOTE_CHOICES, + choices=VOTE_CHOICES if enable_abstention else VOTE_CHOICES_NO_ABSTENTION, widget=forms.RadioSelect(), - initial=None if disable_abstention else VOTE_ABSTENTION, + initial=VOTE_ABSTENTION if enable_abstention else None, **kwargs ) self.application = application @@ -84,14 +84,14 @@ class VoteForm(forms.Form): if self.election.max_votes_yes is not None: self.max_votes_yes = self.election.max_votes_yes else: - self.max_votes_yes = self.election.applications.count() + self.max_votes_yes = self.election.applications.all().count() # dynamically construct form fields - for application in self.election.applications: + for application in self.election.applications.all(): self.fields[f'{application.pk}'] = VoteField(application=application, - disable_abstention=self.election.disable_abstention) + enable_abstention=self.election.enable_abstention) - self.num_applications = len(self.election.applications) + self.num_applications = self.election.applications.all().count() def clean(self): super().clean() diff --git a/vote/migrations/0028_auto_20210804_2335.py b/vote/migrations/0028_auto_20210804_2335.py new file mode 100644 index 0000000000000000000000000000000000000000..5bdd978aa265c923bd3839fc0398aebdd64b5ca2 --- /dev/null +++ b/vote/migrations/0028_auto_20210804_2335.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.13 on 2021-08-04 21:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0027_change_field_type_results_published'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='election', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='vote.election'), + ), + migrations.AlterField( + model_name='application', + name='voter', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='vote.voter'), + ), + ] diff --git a/vote/migrations/0029_rename_unpublished_and_disable_abstention.py b/vote/migrations/0029_rename_unpublished_and_disable_abstention.py new file mode 100644 index 0000000000000000000000000000000000000000..dffbda68eb954e0c5fdf6bdb33460b0f68b3bc60 --- /dev/null +++ b/vote/migrations/0029_rename_unpublished_and_disable_abstention.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.13 on 2021-08-06 08:40 + +from django.db import migrations +from django.db.models import Q + + +def update_values(apps, schema_editor): + election = apps.get_model('vote', 'election') + election.objects.update(result_published=Q(result_published=False)) + election.objects.update(enable_abstention=Q(enable_abstention=False)) + + +class Migration(migrations.Migration): + dependencies = [ + ('vote', '0028_auto_20210804_2335'), + ] + + operations = [ + migrations.RenameField( + model_name='election', + old_name='result_unpublished', + new_name='result_published', + ), + migrations.RenameField( + model_name='election', + old_name='disable_abstention', + new_name='enable_abstention', + ), + migrations.RunPython(update_values, reverse_code=update_values), + ] diff --git a/vote/models.py b/vote/models.py index 9730ae32f1a75f529727e35e698a4df1e703d9f3..e1e00924ee09e7f48f64e57935d0e966e7014836 100644 --- a/vote/models.py +++ b/vote/models.py @@ -97,8 +97,8 @@ class Election(models.Model): end_date = models.DateTimeField(blank=True, null=True) max_votes_yes = models.IntegerField(blank=True, null=True) session = models.ForeignKey(Session, related_name='elections', on_delete=CASCADE) - result_unpublished = models.BooleanField(null=False, default=True) - disable_abstention = models.BooleanField(default=False) + result_published = models.BooleanField(null=False, default=False) + enable_abstention = models.BooleanField(default=True) voters_self_apply = models.BooleanField(default=False) send_emails_on_start = models.BooleanField(default=False) remind_text = models.TextField(max_length=8000, blank=True, null=True) @@ -107,24 +107,24 @@ class Election(models.Model): @property def started(self): if self.start_date is not None: - return timezone.now() > self.start_date + return timezone.now() >= self.start_date return False @property def closed(self): if self.end_date: - return self.end_date < timezone.now() + return self.end_date <= timezone.now() return False @property def is_open(self): if self.start_date and self.end_date: - return self.start_date < timezone.now() < self.end_date + return self.start_date <= timezone.now() < self.end_date if self.start_date: - return self.start_date < timezone.now() + return self.start_date <= timezone.now() return False @@ -135,10 +135,6 @@ class Election(models.Model): return True - @property - def applications(self): - return Application.objects.filter(election=self) - @property def election_summary(self): if not self.closed: @@ -163,9 +159,9 @@ class Election(models.Model): return self.open_votes.count() def number_votes_cast(self): - if self.applications.count() == 0: + if self.applications.all().count() == 0: return 0 - return int(self.votes.count() / self.applications.count()) + return int(self.votes.count() / self.applications.all().count()) def save(self, force_insert=False, force_update=False, using=None, update_fields=None): super().save(force_insert, force_update, using, update_fields) @@ -290,9 +286,12 @@ class Voter(models.Model): def is_active(self): return self.has_usable_password() - def can_vote(self, election): + def can_vote(self, election: Election): return election.is_open and OpenVote.objects.filter(voter_id=self.voter_id, election_id=election.id).exists() + def has_applied(self, election: Election): + return self.applications.filter(election=election).exists() + @property def is_staff(self): return False @@ -458,10 +457,10 @@ def avatar_file_name(instance, filename): class Application(models.Model): text = models.TextField(max_length=250, blank=True) avatar = models.ImageField(upload_to=avatar_file_name, null=True, blank=True) - election = models.ForeignKey(Election, related_name='application', on_delete=models.CASCADE) + election = models.ForeignKey(Election, related_name='applications', on_delete=models.CASCADE) display_name = models.CharField(max_length=256) email = models.EmailField(null=True, blank=True) - voter = models.ForeignKey(Voter, related_name="application", null=True, blank=True, on_delete=models.CASCADE) + voter = models.ForeignKey(Voter, related_name="applications", null=True, blank=True, on_delete=models.CASCADE) _old_avatar = None diff --git a/vote/selectors.py b/vote/selectors.py new file mode 100644 index 0000000000000000000000000000000000000000..f08a82310ea6ea672b80c2a9a40946f2d7c1a0a7 --- /dev/null +++ b/vote/selectors.py @@ -0,0 +1,31 @@ +from django.db.models import Q +from django.utils import timezone + +from vote.models import Election, Session + + +def upcoming_elections(session: Session): + return Election.objects.filter(session=session).filter( + Q(start_date__gt=timezone.now()) | Q(start_date__isnull=True) + ).order_by('start_date') + + +def open_elections(session: Session): + return Election.objects.filter(session=session).filter( + Q(start_date__isnull=False, end_date__isnull=False, start_date__lte=timezone.now(), end_date__gt=timezone.now()) + | Q(start_date__isnull=False, end_date__isnull=True, start_date__lte=timezone.now()) + ).order_by('-start_date') + + +def _closed_elections(session: Session): + return Election.objects.filter(session=session).filter( + Q(end_date__lte=timezone.now(), end_date__isnull=False) + ).order_by('-start_date') + + +def published_elections(session: Session): + return _closed_elections(session).filter(result_published=True) + + +def closed_elections(session: Session): + return _closed_elections(session).filter(result_published=False) diff --git a/vote/templates/vote/index_election_item.html b/vote/templates/vote/index_election_item.html index 6413fa3d6d54ed60209680b06d79add496f5786d..34ad03b6182688c0789dfcfe9e041002ef498fa8 100644 --- a/vote/templates/vote/index_election_item.html +++ b/vote/templates/vote/index_election_item.html @@ -24,7 +24,7 @@ <div class="list-group mt-3"> {% if can_vote %} <a class="btn btn-primary" role="button" href="{% url 'vote:vote' election.pk %}">Vote Now!</a> - {% elif election.closed and not election.result_unpublished %} + {% elif election.closed and election.result_published %} <div class="alert alert-info" role="alert"> <h4 class="alert-heading">Voting Ended:</h4> <hr> @@ -79,7 +79,7 @@ {% endif %} <div class="mt-3"> <div class="row row-cols-1 row-cols-md-2 vote-list"> - {% for application in election.applications|shuffle %} + {% for application in election.applications.all|shuffle %} <div class="col mb-2"> <div class="applicant"> {% if application.avatar %} diff --git a/vote/templates/vote/spectator_election_item.html b/vote/templates/vote/spectator_election_item.html index ce68ec184c4ebe7c066fa5a6349372c4ae05aeb1..e2130ff57c00640efb529fc531d444da6cf75614 100644 --- a/vote/templates/vote/spectator_election_item.html +++ b/vote/templates/vote/spectator_election_item.html @@ -10,7 +10,7 @@ {% endif %} <hr> <div class="list-group mt-3"> - {% if election.closed and not election.result_unpublished %} + {% if election.closed and election.result_published %} <div class="alert alert-info" role="alert"> <h4 class="alert-heading">Election result:</h4> <hr> diff --git a/vote/templates/vote/vote.html b/vote/templates/vote/vote.html index f055095442c7e8fbf6f59c568413c2de77578498..4d8ac08c7baa3263751b2b6da4737377731c1bf7 100644 --- a/vote/templates/vote/vote.html +++ b/vote/templates/vote/vote.html @@ -49,7 +49,7 @@ <thead class="thead-light"> <tr> <th>{% if election.voters_self_apply %}Applicant{% else %}Option{% endif %}</th> - {% if not election.disable_abstention %} + {% if election.enable_abstention %} <th class="choice text-center">Abstention</th> {% endif %} <th class="choice text-center text-success">YES</th> diff --git a/vote/tests.py b/vote/tests.py index a0fe09335bd61463eaae03e3e80876478104e78c..48d990e09635903acd70012f403e262b0ddeaa7d 100644 --- a/vote/tests.py +++ b/vote/tests.py @@ -1,6 +1,11 @@ +from datetime import timedelta, datetime + from django.test import TestCase +from django.utils import timezone +from freezegun import freeze_time -from vote.models import Enc32, Voter, Session +from vote.models import Election, Enc32, Voter, Session +from vote.selectors import closed_elections, open_elections, published_elections, upcoming_elections class Enc32TestCase(TestCase): @@ -21,6 +26,59 @@ class VoterTestCase(TestCase): self.assertEqual(raw_password, ret_password) +class ElectionSelectorsTest(TestCase): + def test_election_selectors(self) -> None: + now = datetime(year=2021, month=4, day=1, tzinfo=timezone.get_fixed_timezone(5)) + before = now - timedelta(seconds=5) + bbefore = now - timedelta(seconds=10) + after = now + timedelta(seconds=5) + freeze_time(now).start() + + session = Session.objects.create(title="TEST") + # upcoming elections + all_upcoming = set() + all_upcoming.add(Election.objects.create(session=session)) + all_upcoming.add(Election.objects.create(session=session, start_date=after)) + # open elections + all_opened = set() + all_opened.add(Election.objects.create(session=session, start_date=now)) + all_opened.add(Election.objects.create(session=session, start_date=before, end_date=after)) + # published elections + all_published = set() + all_published.add(Election.objects.create(session=session, start_date=bbefore, end_date=before, + result_published=True)) + all_published.add(Election.objects.create(session=session, start_date=before, end_date=now, + result_published=True)) + # closed (not published) elections + all_closed = set() + all_closed.add(Election.objects.create(session=session, start_date=bbefore, end_date=before)) + all_closed.add(Election.objects.create(session=session, start_date=before, end_date=now)) + + # test upcoming + upcoming = upcoming_elections(session) + self.assertEqual(all_upcoming, set(upcoming)) + for e in upcoming: + self.assertTrue(not e.started and not e.closed and not e.is_open) + + # test open + opened = open_elections(session) + self.assertEqual(all_opened, set(opened)) + for e in opened: + self.assertTrue(e.started and not e.closed and e.is_open) + + # test published + published = published_elections(session) + self.assertEqual(all_published, set(published)) + for e in published: + self.assertTrue(e.started and e.closed and not e.is_open and e.result_published) + + # test closed + closed = closed_elections(session) + self.assertEqual(all_closed, set(closed)) + for e in closed: + self.assertTrue(e.started and e.closed and not e.is_open and not e.result_published) + + def gen_data(): session = Session.objects.create( title='Test session' diff --git a/vote/urls.py b/vote/urls.py index 5900f5aa04cd95a31008c2cf205a96f476642599..5650bc055785dbb184ac7d43af1b2374522cc778 100644 --- a/vote/urls.py +++ b/vote/urls.py @@ -20,5 +20,5 @@ urlpatterns = [ path('vote/<int:election_id>/apply', views.apply, name='apply'), path('vote/<int:election_id>/delete-own-application', views.delete_own_application, name='delete_own_application'), path('help', views.help_page, name='help'), - path('spectator/<str:uuid>', views.spectator, name='spectator') + path('spectator/<uuid:uuid>', views.spectator, name='spectator') ] diff --git a/vote/views.py b/vote/views.py index 04bc74d6604c7d1d9bd4b1c4bced1b6cd0ecbc1a..315d9b96dc5fc52e6cfaf86173f90f989192f68a 100644 --- a/vote/views.py +++ b/vote/views.py @@ -11,6 +11,7 @@ from ratelimit.decorators import ratelimit from vote.authentication import voter_login_required from vote.forms import AccessCodeAuthenticationForm, VoteForm, ApplicationUploadFormUser from vote.models import Election, Voter, Session +from vote.selectors import open_elections, upcoming_elections, published_elections, closed_elections class LoginView(auth_views.LoginView): @@ -57,32 +58,23 @@ def code_login(request, access_code=None): @voter_login_required def index(request): - voter = request.user - elections = [ - (e, voter.can_vote(e), voter.application.filter(election=e).exists()) - for e in voter.session.elections.order_by('pk') - ] - - def date_asc(e): - date = e[0].start_date - return date.timestamp() if date else sys.maxsize - - def date_desc(e): - date = e[0].start_date - return -date.timestamp() if date else -sys.maxsize - - open_elections = sorted([e for e in elections if e[0].is_open], key=date_desc) - upcoming_elections = sorted([e for e in elections if not e[0].started], key=date_asc) - published_elections = sorted([e for e in elections if e[0].closed and not e[0].result_unpublished], key=date_desc) - closed_elections = sorted([e for e in elections if e[0].closed and e[0].result_unpublished], key=date_desc) + voter: Voter = request.user + session = voter.session + + def list_elections(elections): + return [ + (e, voter.can_vote(e), voter.has_applied(e)) + for e in elections + ] + context = { - 'title': voter.session.title, - 'meeting_link': voter.session.meeting_link, + 'title': session.title, + 'meeting_link': session.meeting_link, 'voter': voter, - 'open_elections': open_elections, - 'upcoming_elections': upcoming_elections, - 'published_elections': published_elections, - 'closed_elections': closed_elections, + 'open_elections': list_elections(open_elections(session)), + 'upcoming_elections': list_elections(upcoming_elections(session)), + 'published_elections': list_elections(published_elections(session)), + 'closed_elections': list_elections(closed_elections(session)), } # overview @@ -91,7 +83,7 @@ def index(request): @voter_login_required def vote(request, election_id): - voter = request.user + voter: Voter = request.user try: election = voter.session.elections.get(pk=election_id) except Election.DoesNotExist: @@ -99,9 +91,9 @@ def vote(request, election_id): can_vote = voter.can_vote(election) if election.max_votes_yes is not None: - max_votes_yes = min(election.max_votes_yes, election.applications.count()) + max_votes_yes = min(election.max_votes_yes, election.applications.all().count()) else: - max_votes_yes = election.applications.count() + max_votes_yes = election.applications.all().count() context = { 'title': election.title, @@ -132,7 +124,7 @@ def apply(request, election_id): ' currently not accepted') return redirect('vote:index') - application = voter.application.filter(election__id=election_id) + application = voter.applications.filter(election__id=election_id) instance = None if application.exists(): instance = application.first() @@ -158,7 +150,7 @@ def apply(request, election_id): def delete_own_application(request, election_id): voter = request.user election = get_object_or_404(voter.session.elections, pk=election_id) - application = voter.application.filter(election__id=election_id) + application = voter.applications.filter(election__id=election_id) if not election.can_apply: messages.add_message(request, messages.ERROR, 'Applications can currently not be deleted') return redirect('vote:index') @@ -176,26 +168,13 @@ def help_page(request): def spectator(request, uuid): session = get_object_or_404(Session.objects, spectator_token=uuid) - elections = session.elections.all() - - def date_asc(e): - date = e.start_date - return date.timestamp() if date else sys.maxsize - - def date_desc(e): - date = e.start_date - return -date.timestamp() if date else -sys.maxsize - open_elections = sorted([e for e in elections if e.is_open], key=date_desc) - upcoming_elections = sorted([e for e in elections if not e.started], key=date_asc) - published_elections = sorted([e for e in elections if e.closed and int(e.result_published)], key=date_desc) - closed_elections = sorted([e for e in elections if e.closed and not int(e.result_published)], key=date_desc) context = { 'title': session.title, 'meeting_link': session.meeting_link, - 'open_elections': open_elections, - 'upcoming_elections': upcoming_elections, - 'published_elections': published_elections, - 'closed_elections': closed_elections, + 'open_elections': open_elections(session), + 'upcoming_elections': upcoming_elections(session), + 'published_elections': published_elections(session), + 'closed_elections': closed_elections(session), } return render(request, template_name='vote/spectator.html', context=context)