Commit 0c14a0af authored by Nicolas Chauvat's avatar Nicolas Chauvat
Browse files

initial commit

parents
^.tox/
\.egg-info/
^.pytest_cache/
FROM debian:buster-slim
RUN apt update && apt install -y python3-flask python3-numpy python3-setuptools
RUN mkdir /tmp/pyvoter-polls
WORKDIR /src
COPY . .
RUN python3 setup.py install
EXPOSE 5000
CMD python3 -m pyvoter /tmp/pyvoter-polls
REGISTRY?=r.ext.logilab.fr/nchauvat/pyvoter
VERSION?=$(shell hg log -r . -T "{sub(':.*', '', '{latesttag}')}{sub('^-0-.*', '', '-{latesttagdistance}-hg{shortest(node, 7)}')}\n")
IMAGE?=$(REGISTRY):$(VERSION)
NAMESPACE=pyvoter
all: build push deploy
build:
docker build --pull -t $(IMAGE) .
push:
docker push $(IMAGE)
docker tag $(IMAGE) $(REGISTRY):latest
docker push $(REGISTRY):latest
deploy:
sed "s@r.ext.logilab.fr/nchauvat/pyvoter@$(IMAGE)@" deploy/deployment.yaml | kubectl -n $(NAMESPACE) apply -f -
kubectl -n $(NAMESPACE) rollout restart deployment/pyvoter
kubectl -n $(NAMESPACE) rollout status deployment/pyvoter --timeout 200s
.PHONY: all build push deploy
Pyvoter
=======
Web application to easily organize polls and compute results
with `majority judgment` or `randomized condorcet`.
[tool.black]
target-version = ['py37']
line-length = 100
exclude = '''(
\(
dist
| sphinx
| \.tox
| \.hg
| \.eggs
| \.pytest_cache
| \.bundle
| \.scannerwork
\)
)'''
# -*- coding: utf-8 -*-
import os
import sys
import uuid
import pathlib
import json
import logging
from pyvoter.votingsystems import RandomizedCondorcet
import jinja2
from flask import Flask, redirect, request
app = Flask(__name__)
# templates
T_MAIN = jinja2.Template(
"""
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>pyvoter - {{ title }}</title>
<link rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
crossorigin="anonymous">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css"
integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU"
crossorigin="anonymous">
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<div>{{ main_content }}</div>
</div>
</body></html>
"""
)
def t_main_template(title, main_content):
return T_MAIN.render(title=title, main_content=main_content)
T_LIST_POLLS = jinja2.Template(
"""
<a href="new_poll" class="btn btn-primary">create new poll</a>
<h2>Existing polls</h2>
<ul>
{% for poll in polls%}
<li><a href="poll/{{ poll }}">{{ poll }}</a></li>
{% endfor %}
</ul>
"""
)
def t_list_polls(polls):
return T_LIST_POLLS.render(polls=polls)
T_POLL = jinja2.Template(
"""
<div>
<h2>Candidates</h2>
<form method="POST">
<div class="form-group">
<textarea name="candidates" rows="10" cols="40">{{ '\n'.join(poll.candidates) }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Update candidates</button>
</form>
</div>
<div>
<h2>Votes ({{ poll.votes|length }})</h2>
<ul>
{% for vote in poll.votes %}
<li>{{ vote.uid }}</li>
{% endfor %}
</ul>
<a href="{{ poll.uid }}/new_vote" class="btn btn-primary">Cast new vote</a>
</div>
<div>
<h2>Result</h2>
{% if poll.result %}
<ul>
{% for row in poll.result %}
<li>{{ ' / '.join(row) }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
"""
)
def t_poll(poll):
return T_POLL.render(poll=poll)
T_VOTE = jinja2.Template(
"""
<form method="POST">
<div class="form-group">
<label for="vote_uid">Login</label>
<input id="vote_uid" name="vote_uid" type="text" class="form-control">
</div>
<div class="form-group">
<label for="vote_text">Order list of candidates to vote (put best option first)</label>
<textarea name="vote" id="vote_text" class="form-control" rows="10" cols="40">
{{ '\n'.join(poll.candidates) }}
</textarea>
</div>
<button type="submit" class="btn btn-primary">Cast vote</button>
</form>
"""
)
def t_vote(poll, vote):
return T_VOTE.render(poll=poll, vote=vote)
# logic / poll
class Poll:
def __init__(self, uid, candidates=None):
self.uid = uid
self.candidates = candidates if candidates else []
self.votes = []
def as_dict(self):
return {"uid": self.uid, "candidates": list(self.candidates)}
def get_polls():
return os.listdir(app.polls_dir)
def get_poll_path(uid):
return pathlib.Path(app.polls_dir) / uid
def read_poll(uid):
poll_path = get_poll_path(uid)
filepath = poll_path / "data.json"
if filepath.exists():
with filepath.open() as fp:
data = json.load(fp)
poll = Poll(uid, data["candidates"])
for subpath in poll_path.iterdir():
if subpath != filepath:
with subpath.open() as vp:
try:
data = json.load(vp)
except json.JSONDecodeError:
logging.warning(f"error reading {subpath} ignoring")
continue
vote = Vote(data["uid"], data["candidates"])
poll.votes.append(vote)
rdct = RandomizedCondorcet(poll.candidates)
for vote in poll.votes:
rdct.cast_vote(vote.uid, vote.candidates)
# debug
import logging
level = logging.getLogger().level
logging.getLogger().setLevel(logging.DEBUG)
poll.result = rdct.compute_result()
logging.getLogger().setLevel(level)
# /debug
print(poll.result)
else:
poll = Poll(uid)
return poll
def write_poll(poll):
filepath = get_poll_path(poll.uid) / "data.json"
with filepath.open("w") as fp:
json.dump(poll.as_dict(), fp)
# logic / vote
def get_vote_path(poll_uid, vote_uid):
return get_poll_path(poll_uid) / f"{vote_uid}.json"
class Vote:
def __init__(self, uid, candidates=None):
self.uid = uid
self.candidates = candidates if candidates else []
def as_dict(self):
return {"uid": self.uid, "candidates": list(self.candidates)}
def read_vote(poll_uid, vote_uid):
filepath = get_vote_path(poll_uid, vote_uid)
if filepath.exists():
with filepath.open() as fp:
data = json.load(fp)
vote = Vote(vote_uid, data["candidates"])
else:
vote = Vote(vote_uid)
return vote
def write_vote(poll, vote):
filepath = get_vote_path(poll.uid, vote.uid)
with filepath.open("w") as fp:
json.dump(vote.as_dict(), fp)
def parse_candidates(text):
for line in text.splitlines():
line = line.strip()
if line:
yield line
# routes
@app.route("/")
def route_home():
return t_main_template("pyvoter", t_list_polls(get_polls()))
@app.route("/new_poll")
def new_poll():
poll_hash = str(uuid.uuid4())
os.mkdir(get_poll_path(poll_hash))
return redirect("/poll/" + poll_hash)
@app.route("/poll/<poll_hash>", methods=["GET", "POST"])
def route_poll(poll_hash):
if request.method == "GET":
poll = read_poll(poll_hash)
return t_main_template(f"poll {poll_hash}", t_poll(poll))
elif request.method == "POST":
candidates = list(parse_candidates(request.form["candidates"]))
poll = Poll(poll_hash, candidates)
write_poll(poll)
return redirect("/poll/" + poll_hash)
@app.route("/poll/<poll_hash>/<vote_hash>", methods=["GET", "POST"])
def route_poll_vote(poll_hash, vote_hash):
poll = read_poll(poll_hash)
if request.method == "GET":
return t_main_template(f"Vote for poll {poll_hash}", t_vote(poll, {}))
elif request.method == "POST":
vote_uid = request.form["vote_uid"]
if not vote_uid.strip():
vote_uid = str(uuid.uuid4())
candidates = parse_candidates(request.form["vote"])
vote = Vote(vote_uid, candidates)
write_vote(poll, vote)
return redirect("/poll/" + poll_hash)
if __name__ == "__main__":
app.polls_dir = sys.argv[1]
app.debug = bool(os.environ.get('DEBUG'))
if app.debug:
app.run()
else:
app.run(host='0.0.0.0')
# appel vote
#
# ---
# option1
# option2
# option3
# vote
# option2
# option1
# ---
# option3
# = préfère 2 à 1 et sans opinion pour 3
import numpy as np
import logging
class Candidates(list):
"""A list of strings identifying candidates"""
class RandomizedCondorcet:
"""A attempt at implementing RandomizedCondorcet voting"""
def __init__(self, candidates):
"""Create a poll with given list of `candidates`"""
self._candidates = candidates
self._votes = {} # {vote_hash: order_list_of_candidates, ...}
def cast_vote(self, uid, vote):
"""uid is a unique string identifying the vote (name, hash, etc)
vote is an ordered list of candidates
"""
if uid in self._votes:
raise KeyError(f"recasting vote {uid}")
if set(vote) != set(self._candidates) or len(vote) != len(self._candidates):
raise ValueError(f"invalid vote {vote} is not a permutation of {self._candidates}")
self._votes[uid] = vote # ordered list of self._candidates
def compute_result(self):
"""TO BE IMPROVED. probably incorrect algorithm, but results look like the right ones
for simple cases"""
logging.debug("candidates", self._candidates)
num_cand = len(self._candidates)
wins_over = np.zeros((num_cand, num_cand))
for uid, vote in self._votes.items():
order = [vote.index(cand) for cand in self._candidates]
logging.debug(f"{uid} voted {order}")
for i in range(num_cand):
for j in range(num_cand):
if order[i] < order[j]:
wins_over[i, j] += 1
elif order[i] > order[j]:
wins_over[i, j] -= 1
logging.debug(
f" i,j={i},{j} order={order[i]},{order[j]} win={order[i] < order[j]}"
)
logging.debug(f"wins_over:\n{wins_over}")
wins = sum(wins_over.T)
logging.debug(f"wins {wins}")
sorted_wins = list(reversed(sorted(set(wins))))
result = []
logging.debug(f"sorted_wins {sorted_wins}")
for score in sorted_wins:
winners = [cand_num for cand_num, cand_score in enumerate(wins) if cand_score == score]
logging.debug(f"score={score} winners={winners}")
result.append({self._candidates[idx] for idx in winners})
logging.debug(f"result={result}")
return result
#!/usr/bin/env python
# coding: utf-8
from setuptools import find_packages, setup
author = "Logilab"
author_email = "contact@logilab.fr"
install_requires = ["flask >= 1.0.2", "numpy"]
setup(
name="pyvoter",
version="0.1.0",
license="GPL",
description="web app to set up polls instantly (majority judgment or Condordcet)",
author=author,
author_email=author_email,
install_requires=install_requires,
packages=find_packages(exclude=["test"]),
include_package_data=True,
zip_safe=False,
)
import unittest
from pyvoter.votingsystems import RandomizedCondorcet
class RandomizedCondorcetTC(unittest.TestCase):
def test_single_vote1(self):
poll = RandomizedCondorcet(["A", "B"])
poll.cast_vote("vote1", ["A", "B"])
result = poll.compute_result()
self.assertEqual(result, [{"A"}, {"B"}])
def test_single_vote2(self):
poll = RandomizedCondorcet(["A", "B"])
poll.cast_vote("vote1", ["B", "A"])
result = poll.compute_result()
self.assertEqual(result, [{"B"}, {"A"}])
def test_double_vote_same(self):
poll = RandomizedCondorcet(["A", "B"])
poll.cast_vote("vote1", ["A", "B"])
poll.cast_vote("vote2", ["A", "B"])
result = poll.compute_result()
self.assertEqual(result, [{"A"}, {"B"}])
def test_double_vote_opposed(self):
poll = RandomizedCondorcet(["A", "B"])
poll.cast_vote("vote1", ["A", "B"])
poll.cast_vote("vote2", ["B", "A"])
result = poll.compute_result()
self.assertEqual(result, [{"A", "B"}])
def test_triple_vote_same(self):
poll = RandomizedCondorcet(["A", "B"])
poll.cast_vote("vote1", ["A", "B"])
poll.cast_vote("vote2", ["A", "B"])
poll.cast_vote("vote3", ["A", "B"])
result = poll.compute_result()
self.assertEqual(result, [{"A"}, {"B"}])
def test_triple_vote_differ(self):
poll = RandomizedCondorcet(["A", "B"])
poll.cast_vote("vote1", ["A", "B"])
poll.cast_vote("vote2", ["A", "B"])
poll.cast_vote("vote3", ["B", "A"])
result = poll.compute_result()
self.assertEqual(result, [{"A"}, {"B"}])
def test_vote_paradox(self):
poll = RandomizedCondorcet(["A", "B", "C"])
poll.cast_vote("vote1", ["A", "B", "C"])
poll.cast_vote("vote2", ["B", "C", "A"])
poll.cast_vote("vote3", ["C", "A", "B"])
result = poll.compute_result()
self.assertEqual(result, [{"A", "B", "C"}])
def test_bad_vote(self):
poll = RandomizedCondorcet(["A", "B", "C"])
with self.assertRaises(ValueError):
poll.cast_vote("vote1", ["A", "B", "X"])
[tox]
envlist = py3,flake8,black
[testenv]
deps =
pytest
commands =
{envpython} -m pytest {posargs:test}
[testenv:flake8]
basepython = python3
skip_install = true
deps =
flake8
commands = flake8
[testenv:black]
basepython = python3
skip_install = true
deps =
black
commands = black --check .
[flake8]
exclude = test/data/*,doc/*,.tox/*
max-line-length = 100
extend-ignore = E203, E731, E231
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment