Skip to content

Commit 23e6be1

Browse files
authored
Merge pull request #282 from python-discord/jb3/features/condorcet-count
Add Condorcet count endpoint
2 parents cdda92c + 3b6464f commit 23e6be1

File tree

4 files changed

+207
-2
lines changed

4 files changed

+207
-2
lines changed

backend/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import sentry_sdk
22
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
33
from starlette.applications import Starlette
4+
from starlette.exceptions import HTTPException
45
from starlette.middleware import Middleware
56
from starlette.middleware.authentication import AuthenticationMiddleware
67
from starlette.middleware.cors import CORSMiddleware
8+
from starlette.requests import Request
9+
from starlette.responses import JSONResponse
710

811
from backend import constants
912
from backend.authentication import JWTAuthenticationBackend
@@ -47,5 +50,14 @@
4750
Middleware(ProtectedDocsMiddleware),
4851
]
4952

50-
app = Starlette(routes=create_route_map(), middleware=middleware)
53+
54+
async def http_exception(_request: Request, exc: HTTPException) -> JSONResponse: # noqa: RUF029
55+
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
56+
57+
58+
exception_handlers = {HTTPException: http_exception}
59+
60+
app = Starlette(
61+
routes=create_route_map(), middleware=middleware, exception_handlers=exception_handlers
62+
)
5163
api.register(app)

backend/routes/forms/condorcet.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
if question.type != "vote":
82+
raise InvalidCondorcetRequest(
83+
detail="Requested question is not a condorcet vote component", status_code=400
84+
)
85+
86+
cursor = request.state.db.responses.find(
87+
{"form_id": form_id},
88+
)
89+
responses = [FormResponse(**response) for response in await cursor.to_list(None)]
90+
91+
votes = [
92+
reprocess_vote_object(response.response[question_id], len(question.data["options"]))
93+
for response in responses
94+
]
95+
96+
evaluator = CondorcetEvaluator(candidates=question.data["options"], votes=votes)
97+
98+
winners, rest_of_table = evaluator.get_n_winners(num_winners)
99+
100+
return JSONResponse({
101+
"question": question.dict(),
102+
"winners": winners,
103+
"rest_of_table": rest_of_table,
104+
})

poetry.lock

Lines changed: 89 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pydantic = "^1.10.17"
1818
spectree = "^1.2.10"
1919
deepmerge = "^1.1.1"
2020
sentry-sdk = "^2.7.1"
21+
condorcet = "^0.1.1"
2122

2223
[tool.poetry.group.dev.dependencies]
2324
ruff = "^0.5.1"

0 commit comments

Comments
 (0)