Skip to content

Commit b5bb7e0

Browse files
committed
Add new endpoint for performing condorcet calculation on form responses
1 parent cb2dd7e commit b5bb7e0

File tree

1 file changed

+99
-0
lines changed

1 file changed

+99
-0
lines changed

backend/routes/forms/condorcet.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Calculate the condorcet winner for a given question on a poll."""
2+
3+
from condorcet import CondorcetEvaluator
4+
from pydantic import BaseModel
5+
from spectree import Response
6+
from starlette import exceptions
7+
from starlette.authentication import requires
8+
from starlette.requests import Request
9+
from starlette.responses import JSONResponse
10+
11+
from backend import discord
12+
from backend.models import Form, FormResponse, Question
13+
from backend.route import Route
14+
from backend.validation import api
15+
16+
17+
class CondorcetResponse(BaseModel):
18+
question: Question
19+
winners: list[str]
20+
rest_of_table: dict
21+
22+
23+
class InvalidCondorcetRequest(exceptions.HTTPException):
24+
"""The request for a condorcet calculation was invalid."""
25+
26+
27+
def reprocess_vote_object(vote: dict[str, int | None], number_options: int) -> dict[str, int]:
28+
"""Reprocess votes so all no-preference votes are re-ranked as last (equivalent in Condorcet)."""
29+
vote_object = {}
30+
31+
for option, score in vote.items():
32+
vote_object[option] = score or number_options
33+
34+
return vote_object
35+
36+
37+
class Condorcet(Route):
38+
"""Run a condorcet calculation on the given question on a form."""
39+
40+
name = "form_condorcet"
41+
path = "/{form_id:str}/condorcet/{question_id:str}"
42+
43+
@requires(["authenticated"])
44+
@api.validate(
45+
resp=Response(HTTP_200=CondorcetResponse),
46+
tags=["forms", "responses", "condorcet"],
47+
)
48+
async def get(self, request: Request) -> JSONResponse:
49+
"""
50+
Run and return the condorcet winner for a poll.
51+
52+
Optionally takes a `?winners=` parameter specifying the number of winners to calculate.
53+
"""
54+
form_id = request.path_params["form_id"]
55+
question_id = request.path_params["question_id"]
56+
num_winners = request.query_params.get("winners", "1")
57+
58+
try:
59+
num_winners = int(num_winners)
60+
except ValueError:
61+
raise InvalidCondorcetRequest(detail="Invalid number of winners", status_code=400)
62+
63+
await discord.verify_response_access(form_id, request)
64+
65+
# We can assume we have a form now because verify_response_access
66+
# checks for form existence.
67+
form_data = Form(**(await request.state.db.forms.find_one({"_id": form_id})))
68+
69+
questions = [question for question in form_data.questions if question.id == question_id]
70+
71+
if len(questions) != 1:
72+
raise InvalidCondorcetRequest(detail="Question not found", status_code=400)
73+
74+
question = questions[0]
75+
76+
if num_winners > len(question.data["options"]):
77+
raise InvalidCondorcetRequest(
78+
detail="Requested more winners than there are candidates", status_code=400
79+
)
80+
81+
cursor = request.state.db.responses.find(
82+
{"form_id": form_id},
83+
)
84+
responses = [FormResponse(**response) for response in await cursor.to_list(None)]
85+
86+
votes = [
87+
reprocess_vote_object(response.response[question_id], len(question.data["options"]))
88+
for response in responses
89+
]
90+
91+
evaluator = CondorcetEvaluator(candidates=question.data["options"], votes=votes)
92+
93+
winners, rest_of_table = evaluator.get_n_winners(num_winners)
94+
95+
return JSONResponse({
96+
"question": question.dict(),
97+
"winners": winners,
98+
"rest_of_table": rest_of_table,
99+
})

0 commit comments

Comments
 (0)