From 8e0cd7d3b5eaf8daaf6ccc63c733cbb0a82b5c14 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Thu, 23 Jan 2025 06:15:38 -0800 Subject: [PATCH] Add proportional to results page (#218) * Add proportional to results page * Modify template. * Trunk formatting. Fix little nits. * Fixed styling. LGTM * Trunk formatting. Fix little nits. * Sanitize branchnames for deployment * Sanitize branchnames for deployment * Change url name * change fly review specs and have them autosuspend. * Set django settings module * Set django settings module * Fix fly allowed hosts issue * Add fly url to CORS CSRF whitelists. * Add https to origins * Finally think we're ready for review. --- .github/workflows/fly-review.yml | 6 +- approval_polls/settings.py | 18 +++++- approval_polls/templates/results.html | 92 ++++++++++++++++++++++++++- approval_polls/views.py | 52 ++++++++++++--- fly.pr-review.toml | 33 ++++++++++ 5 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 fly.pr-review.toml diff --git a/.github/workflows/fly-review.yml b/.github/workflows/fly-review.yml index db3f246..b08e4e3 100644 --- a/.github/workflows/fly-review.yml +++ b/.github/workflows/fly-review.yml @@ -10,7 +10,7 @@ on: env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} # Set these to your Fly.io organization and preferred region. - FLY_REGION: iad + FLY_REGION: sjc FLY_ORG: personal jobs: @@ -36,4 +36,6 @@ jobs: id: deploy uses: superfly/fly-pr-review-apps@1.2.1 with: - secrets: DEBUG=true + name: vote-es-pr-${{ github.event.number }} + config: fly.pr-review.toml + secrets: DJANGO_SETTINGS_MODULE=approval_polls.settings diff --git a/approval_polls/settings.py b/approval_polls/settings.py index cc83875..c1d4145 100644 --- a/approval_polls/settings.py +++ b/approval_polls/settings.py @@ -31,11 +31,14 @@ # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts APP_NAME = env("FLY_APP_NAME", str, "") ALLOWED_HOSTS = [f"{APP_NAME}.fly.dev", "vote.electionscience.org"] # ← Updated! +print("Allowed Hosts: ", ALLOWED_HOSTS) +print("APP_NAME: ", APP_NAME) if DEBUG: db_path = os.path.join(BASE_DIR, "db.sqlite3") ALLOWED_HOSTS.extend(["localhost", "0.0.0.0", "127.0.0.1"]) # trunk-ignore(bandit) + if not DEBUG: COMPRESS_OFFLINE = True LIBSASS_OUTPUT_STYLE = "compressed" @@ -49,9 +52,18 @@ # We recommend adjusting this value in production. profiles_sample_rate=1.0, ) - CSRF_TRUSTED_ORIGINS = ["https://vote.electionscience.org"] - CSRF_ALLOWED_ORIGINS = ["https://vote.electionscience.org"] - CORS_ORIGINS_WHITELIST = ["https://vote.electionscience.org"] + CSRF_TRUSTED_ORIGINS = [ + "https://vote.electionscience.org", + f"https://{APP_NAME}.fly.dev", + ] + CSRF_ALLOWED_ORIGINS = [ + "https://vote.electionscience.org", + f"https://{APP_NAME}.fly.dev", + ] + CORS_ORIGINS_WHITELIST = [ + "https://vote.electionscience.org", + f"https://{APP_NAME}.fly.dev", + ] if "test" in sys.argv or "pytest" in sys.argv: diff --git a/approval_polls/templates/results.html b/approval_polls/templates/results.html index 7f7f699..1fbe713 100644 --- a/approval_polls/templates/results.html +++ b/approval_polls/templates/results.html @@ -9,6 +9,7 @@

{% if poll.is_closed and poll.total_votes == 0 %}No votes in this poll{% endif %}

+
{% for choice in choices %}
@@ -29,9 +30,7 @@

- - + {% endif %}

@@ -47,6 +46,56 @@

+
+
+

+ +

+
+
+

+ When electing multiple candidates to a board or committee Proportional Approval Voting ensures that no single voting group dominates the outcome, promoting fair representation and reflecting the diverse preferences of all voters. +

+ +
+ +
+ + + + + + + + + + {% for result in proportional_results %} + + + + + + {% endfor %} + +
ChoiceProportional VotesPercentage
{{ result.choice_text }}{{ result.proportional_votes|floatformat:2 }}{{ result.proportional_percentage|floatformat:2 }}%
+
+
+
+
+

+ {% endif %} +
{% if 'invitation' in request.META.HTTP_REFERER %} Back to poll @@ -55,4 +104,41 @@

+ {% endblock %} diff --git a/approval_polls/views.py b/approval_polls/views.py index 9e970f4..2dc6ff9 100644 --- a/approval_polls/views.py +++ b/approval_polls/views.py @@ -8,7 +8,7 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count +from django.db.models import Count, Prefetch from django.http import HttpResponseRedirect, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -17,7 +17,14 @@ from django.views import generic from django.views.decorators.http import require_http_methods -from approval_polls.models import Ballot, Poll, PollTag, Subscription, VoteInvitation +from approval_polls.models import ( + Ballot, + Poll, + PollTag, + Subscription, + Vote, + VoteInvitation, +) from .forms import ManageSubscriptionsForm, NewUsernameForm @@ -277,27 +284,54 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) poll = self.object - # Annotate choices with vote count and order by votes + # Approval voting logic choices = poll.choice_set.annotate(vote_count=Count("vote")).order_by( "-vote_count" ) - - # Calculate max votes max_votes = choices.first().vote_count if choices.exists() else 0 - - # Determine leading choices leading_choices = [ choice for choice in choices if choice.vote_count == max_votes ] + # Proportional voting logic + ballots = poll.ballot_set.prefetch_related( + Prefetch("vote_set", queryset=Vote.objects.select_related("choice")) + ) + proportional_votes = {choice.id: 0 for choice in poll.choice_set.all()} + total_proportional_votes = 0 + + for ballot in ballots: + approved_choices = ballot.vote_set.all().values_list("choice_id", flat=True) + num_approved = len(approved_choices) + if num_approved > 0: + weight = 1 / num_approved + for choice_id in approved_choices: + proportional_votes[choice_id] += weight + total_proportional_votes += weight + + proportional_results = [ + { + "choice_text": choice.choice_text, + "proportional_votes": proportional_votes[choice.id], + "proportional_percentage": ( + proportional_votes[choice.id] / total_proportional_votes * 100 + if total_proportional_votes > 0 + else 0 + ), + } + for choice in poll.choice_set.all() + ] + + # Add data to context context.update( { - "choices": choices, + "choices": choices, # Approval results "leading_choices": leading_choices, "max_votes": max_votes, + "proportional_results": proportional_results, # Proportional results + "total_proportional_votes": total_proportional_votes, } ) - return context diff --git a/fly.pr-review.toml b/fly.pr-review.toml new file mode 100644 index 0000000..cd1893f --- /dev/null +++ b/fly.pr-review.toml @@ -0,0 +1,33 @@ +# fly.toml app configuration file generated for vote-electionscience-org on 2024-01-27T11:17:40-08:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'vote-electionscience-org' +primary_region = 'sjc' +console_command = '/code/manage.py shell' + +[build] + +[env] +PORT = '8000' + +[http_service] +auto_stop_machines = "suspend" +internal_port = 8000 +force_https = true +auto_start_machines = true +min_machines_running = 0 +processes = ['app'] + +[[vm]] +kind = 'shared-cpu-1x' +memory_mb = 256 + +[[statics]] +guest_path = '/code/static' +url_prefix = '/static/' + +[mounts] +source = "litefs" +destination = "/data"