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/views.py b/management/views.py index d2592d611e9f6017adfa1c56c8aeb119cb9ba223..cf0f04014d74a73625a5a68d2295064d5324abcb 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) diff --git a/vote/forms.py b/vote/forms.py index 1c3f878265bb8895c94c4ca8df69e73c176f99de..7e0b986ae837d2c822f51ffcde78e7fcafe98fab 100644 --- a/vote/forms.py +++ b/vote/forms.py @@ -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) - 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/models.py b/vote/models.py index 9730ae32f1a75f529727e35e698a4df1e703d9f3..db30e085ef601182f77ddebbdbdfa08cac946d3f 100644 --- a/vote/models.py +++ b/vote/models.py @@ -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..e40ccc93d6d83631f048bdb9f74a2ba38d2c4b18 --- /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_unpublished=False) + + +def closed_elections(session: Session): + return _closed_elections(session).filter(result_unpublished=True) diff --git a/vote/templates/vote/index_election_item.html b/vote/templates/vote/index_election_item.html index 6413fa3d6d54ed60209680b06d79add496f5786d..7a82c6d497115e6b0bd1871be6592592df0f5c4f 100644 --- a/vote/templates/vote/index_election_item.html +++ b/vote/templates/vote/index_election_item.html @@ -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/tests.py b/vote/tests.py index a0fe09335bd61463eaae03e3e80876478104e78c..272439f950bb2369ac5fe9cb8d4b128ecc30e043 100644 --- a/vote/tests.py +++ b/vote/tests.py @@ -1,6 +1,10 @@ +from datetime import timedelta + from django.test import TestCase +from django.utils import timezone -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 +25,58 @@ class VoterTestCase(TestCase): self.assertEqual(raw_password, ret_password) +class ElectionSelectorsTest(TestCase): + def test_election_selectors(self) -> None: + now = timezone.now() # but why does freezegun not work in the query sets + before = now - timedelta(minutes=50) + bbefore = now - timedelta(minutes=100) + after = now + timedelta(minutes=50) + + 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_unpublished=False)) + all_published.add(Election.objects.create(session=session, start_date=before, end_date=now, + result_unpublished=False)) + # 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 not e.result_unpublished) + + # 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 e.result_unpublished) + + def gen_data(): session = Session.objects.create( title='Test session' 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)