From 63ac71cf9ae35d96801a50d2ef444fc9b7597eee Mon Sep 17 00:00:00 2001 From: "Peter J. Holzer" Date: Sun, 6 Oct 2024 13:37:30 +0200 Subject: [PATCH] Specify preference on a 0..100 scale --- app.py | 269 +++++++++++---------------- meeat.procrusql | 7 +- static/style.css | 34 +++- templates/date_result_fragment.html | 10 +- templates/date_result_matrix.html | 47 +++++ templates/date_vote_form.html | 10 + templates/date_vote_fragment.html | 16 +- templates/place_result_fragment.html | 14 +- templates/place_result_matrix.html | 47 +++++ templates/place_vote_form.html | 10 + templates/place_vote_fragment.html | 16 +- templates/time_result_fragment.html | 10 +- templates/time_result_matrix.html | 47 +++++ templates/time_vote_form.html | 10 + templates/time_vote_fragment.html | 16 +- templates/vote.html | 56 ++---- 16 files changed, 354 insertions(+), 265 deletions(-) create mode 100644 templates/date_result_matrix.html create mode 100644 templates/date_vote_form.html create mode 100644 templates/place_result_matrix.html create mode 100644 templates/place_vote_form.html create mode 100644 templates/time_result_matrix.html create mode 100644 templates/time_vote_form.html diff --git a/app.py b/app.py index 3f1e083..7595d3c 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,10 @@ +import collections import datetime import email.message import logging import logging.config import os +import re import smtplib import psycopg @@ -110,70 +112,61 @@ def vote(key): csr.execute( """ - select d.id, d.date, d.display, position + select d.id, d.date, d.display, preference from date d left join date_vote v on d.id = v.date and v.bod = %s - where meet = %s order by position, d.date + where meet = %s order by d.date """, (session["user"]["id"], meet.id,)) dates = csr.fetchall() csr.execute( """ - select d.id, d.time, d.display, position + select d.id, d.time, d.display, preference from time d left join time_vote v on d.id = v.time and v.bod = %s - where meet = %s order by position, d.time + where meet = %s order by d.time """, (session["user"]["id"], meet.id,)) times = csr.fetchall() csr.execute( """ - select d.id, d.name, position + select d.id, d.name, preference from place d left join place_vote v on d.id = v.place and v.bod = %s - where meet = %s order by position, d.name + where meet = %s order by d.name """, (session["user"]["id"], meet.id,)) places = csr.fetchall() return render_template("vote.html", - meet=meet, voters=voters,dates=dates, times=times, places=places) + meet=meet, voters=voters, dates=dates, times=times, places=places) @app.post("/vote/date") def vote_date(): log.debug("form = %s", request.form) - date_ids = request.form.getlist("date") + meet_id, preferences = get_preferences("date") + date_ids = list(preferences.keys()) csr = get_cursor() - # Retrieve the meet.id from the date ids. This also ensures that all the - # dates refer to the same meet. - csr.execute("select distinct meet from date where id = any (%s)", - (date_ids,)) - r = csr.fetchall() - if len(r) != 1: - # this should never happen. Is the client messing around? - log.warning("Date ids %s map to meets %s", date_ids, r) - abort(400) - meet_id = r[0].meet - csr.execute( "delete from date_vote where date = any (%s) and bod = %s", (date_ids, session["user"]["id"])) - for pos, date_id in enumerate(date_ids): + for date_id, pref in preferences.items(): csr.execute( - "insert into date_vote(date, bod, position) values(%s, %s, %s)", - (date_id, session["user"]["id"], pos) + "insert into date_vote(date, bod, preference) values(%s, %s, %s)", + (date_id, session["user"]["id"], pref) ) + # XXX - (almost) duplicate csr.execute( """ - select d.id, d.date, d.display, position + select d.id, d.date, d.display, preference from date d left join date_vote v on d.id = v.date and v.bod = %s - where meet = %s order by position, d.date + where meet = %s order by d.date """, (session["user"]["id"], meet_id,)) dates = csr.fetchall() - result = instantrunoff_backward(meet_id, "date") + result = simple_sum(meet_id, "date") log.debug("result = %s", result) csr.execute( @@ -194,7 +187,7 @@ def vote_date(): @app.get("/result//date") def result_date(meet_id): - result = instantrunoff_backward(meet_id, "date") + result = simple_sum(meet_id, "date") log.debug("result = %s", result) csr = get_cursor() @@ -219,39 +212,30 @@ def result_date(meet_id): @app.post("/vote/time") def vote_time(): log.debug("form = %s", request.form) - time_ids = request.form.getlist("time") + meet_id, preferences = get_preferences("time") + time_ids = list(preferences.keys()) csr = get_cursor() - # Retrieve the meet.id from the time ids. This also ensures that all the - # times refer to the same meet. - csr.execute("select distinct meet from time where id = any (%s)", - (time_ids,)) - r = csr.fetchall() - if len(r) != 1: - # this should never happen. Is the client messing around? - log.warning("Time ids %s map to meets %s", time_ids, r) - abort(400) - meet_id = r[0].meet - csr.execute( "delete from time_vote where time = any (%s) and bod = %s", (time_ids, session["user"]["id"])) - for pos, time_id in enumerate(time_ids): + for time_id, pref in preferences.items(): csr.execute( - "insert into time_vote(time, bod, position) values(%s, %s, %s)", - (time_id, session["user"]["id"], pos) + "insert into time_vote(time, bod, preference) values(%s, %s, %s)", + (time_id, session["user"]["id"], pref) ) + # XXX - (almost) duplicate csr.execute( """ - select d.id, d.time, d.display, position + select d.id, d.time, d.display, preference from time d left join time_vote v on d.id = v.time and v.bod = %s - where meet = %s order by position, d.time + where meet = %s order by d.time """, (session["user"]["id"], meet_id,)) times = csr.fetchall() - result = instantrunoff_backward(meet_id, "time") + result = simple_sum(meet_id, "time") log.debug("result = %s", result) csr.execute( @@ -272,7 +256,7 @@ def vote_time(): @app.get("/result//time") def result_time(meet_id): - result = instantrunoff_backward(meet_id, "time") + result = simple_sum(meet_id, "time") log.debug("result = %s", result) csr = get_cursor() @@ -295,39 +279,30 @@ def result_time(meet_id): @app.post("/vote/place") def vote_place(): log.debug("form = %s", request.form) - place_ids = request.form.getlist("place") + meet_id, preferences = get_preferences("place") + place_ids = list(preferences.keys()) csr = get_cursor() - # Retrieve the meet.id from the place ids. This also ensures that all the - # places refer to the same meet. - csr.execute("select distinct meet from place where id = any (%s)", - (place_ids,)) - r = csr.fetchall() - if len(r) != 1: - # this should never happen. Is the client messing around? - log.warning("Place ids %s map to meets %s", place_ids, r) - abort(400) - meet_id = r[0].meet - csr.execute( "delete from place_vote where place = any (%s) and bod = %s", (place_ids, session["user"]["id"])) - for pos, place_id in enumerate(place_ids): + for place_id, pref in preferences.items(): csr.execute( - "insert into place_vote(place, bod, position) values(%s, %s, %s)", - (place_id, session["user"]["id"], pos) + "insert into place_vote(place, bod, preference) values(%s, %s, %s)", + (place_id, session["user"]["id"], pref) ) + # XXX - (almost) duplicate csr.execute( """ - select d.id, d.name, position + select d.id, d.name, preference from place d left join place_vote v on d.id = v.place and v.bod = %s - where meet = %s order by position, d.name + where meet = %s order by d.name """, (session["user"]["id"], meet_id,)) places = csr.fetchall() - result = instantrunoff_backward(meet_id, "place") + result = simple_sum(meet_id, "place") log.debug("result = %s", result) csr.execute( @@ -349,7 +324,7 @@ def vote_place(): @app.get("/result//place") def result_place(meet_id): - result = instantrunoff_backward(meet_id, "place") + result = simple_sum(meet_id, "place") log.debug("result = %s", result) csr = get_cursor() @@ -370,32 +345,35 @@ def result_place(meet_id): result=result, voters=voters) -@app.get("/result//date/ballot") -def result_ballots_date(meet_id): - ballots = get_ballots(meet_id, "date") - result = instantrunoff_backward(meet_id, "date") +@app.get("/result//date/matrix") +def result_matrix_date(meet_id): + candidates, voters, preference, candidate_sum, voter_max = get_matrix(meet_id, "date") return render_template( - "date_result_ballots.html", - result=result, ballots=ballots) + "date_result_matrix.html", + candidates=candidates, voters=voters, preference=preference, + candidate_sum=candidate_sum, voter_max=voter_max + ) -@app.get("/result//time/ballot") -def result_ballots_time(meet_id): - ballots = get_ballots(meet_id, "time") - result = instantrunoff_backward(meet_id, "time") +@app.get("/result//time/matrix") +def result_matrix_time(meet_id): + candidates, voters, preference, candidate_sum, voter_max = get_matrix(meet_id, "time") return render_template( - "time_result_ballots.html", - result=result, ballots=ballots) + "time_result_matrix.html", + candidates=candidates, voters=voters, preference=preference, + candidate_sum=candidate_sum, voter_max=voter_max + ) -@app.get("/result//place/ballot") -def result_ballots_place(meet_id): - ballots = get_ballots(meet_id, "place") - result = instantrunoff_backward(meet_id, "place") +@app.get("/result//place/matrix") +def result_matrix_place(meet_id): + candidates, voters, preference, candidate_sum, voter_max = get_matrix(meet_id, "place") return render_template( - "place_result_ballots.html", - result=result, ballots=ballots) + "place_result_matrix.html", + candidates=candidates, voters=voters, preference=preference, + candidate_sum=candidate_sum, voter_max=voter_max + ) def send_mail(email_address, confirmation_url): @@ -408,96 +386,69 @@ def send_mail(email_address, confirmation_url): mta = smtplib.SMTP(host="localhost") mta.send_message(msg) -def get_ballots(meet_id, kind): +def get_matrix(meet_id, kind): csr = get_cursor() q = f""" - select {kind}.*, bod, position, email, - min(w) over(partition by bod order by position) as vote_w + select {kind}.*, preference, email, short from {kind} join {kind}_vote on {kind}.id = {kind}_vote.{kind} join bod on bod = bod.id where meet = %s - order by bod, position + order by preference desc """ csr.execute(q, (meet_id,)) - - last_bod = None - ballots = [] - for r in csr: - if r.bod != last_bod: - ballot = [] - ballots.append(ballot) - last_bod = r.bod - ballot.append(r) - return ballots - -def dump_ballots(ballots): - for ballot in ballots: - log.debug ("---") - for r in ballot: - log.debug(r) - -def runoff(ballots, direction): - count = {} candidates = {} - for ballot in ballots: - for r in ballot: - if r.id not in count or len(count[r.id]) < len(ballot): - log.debug("count[%d] <- %d elements", r.id, len(ballot)) - count[r.id] = [0] * len(ballot) - candidates[r.id] = r - for ballot in ballots: - weight = max(r.vote_w for r in ballot) - for pos, r in enumerate(ballot): - log.debug("count = %s", count) - log.debug("r.id = %s", r.id) - log.debug("pos = %s", pos) - count[r.id][pos] += weight - log.debug("count[%d][%d]) = %d", r.id, pos, count[r.id][pos]) - if direction == "backward": - result = sorted(count.keys(), key=lambda i: list(reversed(count[i])), reverse=True) - else: - result = sorted(count.keys(), key=lambda i: count[i]) - log.debug("result of this round:") - for r in result: - log.debug("%s %s", r, count[r]) - log.debug("striking %d", result[0]) - loser = candidates[result[0]] - new_ballots = [ - [ - r for r in ballot if r.id != loser.id - ] for ballot in ballots - ] - return loser, new_ballots + voters = {} + voter_max = collections.defaultdict(float) + candidate_sum = collections.defaultdict(float) + preference = collections.defaultdict(dict) + for r in csr: + candidates[r.id] = r + voters[r.email] = r + preference[r.id][r.email] = r.preference + if r.preference > voter_max[r.email]: + voter_max[r.email] = r.preference + candidate_sum[r.id] += r.preference + app.logger.debug(f"{candidates=}") + return candidates, voters, preference, candidate_sum, voter_max -def instantrunoff_forward(meet_id, kind): - ballots = get_ballots(meet_id, kind) +def simple_sum(meet_id, kind): + q = \ + f""" + select k.*, sum(preference) as preference + from {kind} k join {kind}_vote v on k.id = v.{kind} + where k.meet = %s + group by k.id order by preference desc + """ + csr = get_cursor() + csr.execute(q, (meet_id,)) + return csr.fetchall() - result = [] - while max(len(b) for b in ballots): - dump_ballots(ballots) - loser, ballots = runoff(ballots, "forward") - result.append(loser) - result = list(reversed(result)) - log.debug("final result") - for r in result: - log.debug(r) - return result +def get_preferences(kind): + preferences = {} + for k, v in request.form.items(): + if m := re.match(kind + r"_(\d+)", k): + candidate = int(m.group(1)) + preference = float(v) + if not (0 <= preference <= 100): + # this should never happen. Is the client messing around? + log.warning(f"Preference {preference} not in range [0, 100]") + abort(400) + preferences[candidate] = preference -def instantrunoff_backward(meet_id, kind): - ballots = get_ballots(meet_id, kind) - - result = [] - while max(len(b) for b in ballots): - dump_ballots(ballots) - loser, ballots = runoff(ballots, "backward") - result.append(loser) - result = list(reversed(result)) - log.debug("final result") - for r in result: - log.debug(r) - return result + csr = get_cursor() + # Retrieve the meet.id from the kind ids. This also ensures that all + # refer to the same meet. + csr.execute(f"select distinct meet from {kind} where id = any (%s)", + (list(preferences.keys()),)) + r = csr.fetchall() + if len(r) != 1: + # this should never happen. Is the client messing around? + log.warning(f"{kind} ids {list(preferences.keys())} map to meets {r}") + abort(400) + meet_id = r[0].meet + return meet_id, preferences def get_cursor(): db = get_db() diff --git a/meeat.procrusql b/meeat.procrusql index c2c7f8b..e0f9670 100644 --- a/meeat.procrusql +++ b/meeat.procrusql @@ -23,20 +23,21 @@ column place meet int references meet not null table bod column bod id serial primary key column bod email text not null unique +column bod short text column bod key text unique column bod keychange timestamptz default now() table date_vote column date_vote date int not null references date column date_vote bod int not null references bod -column date_vote position int not null +column date_vote preference float4 not null table time_vote column time_vote time int not null references time column time_vote bod int not null references bod -column time_vote position int not null +column time_vote preference float4 not null table place_vote column place_vote place int not null references place column place_vote bod int not null references bod -column place_vote position int not null +column place_vote preference float4 not null diff --git a/static/style.css b/static/style.css index a53cc93..a08ae3f 100644 --- a/static/style.css +++ b/static/style.css @@ -51,12 +51,18 @@ h2 { } .result-item { - padding: 1em; - border: #CCC 1px solid; - border-radius: 0.2em; color: #888; } +.result-item:first-child { + color: #484; + font-size: 1.5rem; +} + +li.result-item::marker { + font-size: 1.0rem; +} + .ballot { border: 1px solid black; margin: 1em; @@ -126,3 +132,25 @@ h2 { opacity: 100% } } + +.vote-item { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-block: 1rem; +} + +label { + font-size: 1.5rem; +} +.emoji { + font-size: 2rem; +} + +table.matrix th { + text-align: right; +} + +table.matrix td { + text-align: right; +} diff --git a/templates/date_result_fragment.html b/templates/date_result_fragment.html index 73514ef..c7d0565 100644 --- a/templates/date_result_fragment.html +++ b/templates/date_result_fragment.html @@ -1,8 +1,10 @@
- Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): +

Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): +

    {% for r in result %} -
    - {{ loop.index }}. {{ r.display or r.date }} -
    +
  1. + {{ r.display or r.date }} ({{r.preference}}) +
  2. {% endfor %} +
diff --git a/templates/date_result_matrix.html b/templates/date_result_matrix.html new file mode 100644 index 0000000..8ac79c6 --- /dev/null +++ b/templates/date_result_matrix.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + {% for c in candidates.values() %} + + {% endfor %} + + {% for v in voters.values() %} + + + {% for c in candidates.values() %} + + {% endfor %} + + + {% endfor %} + + + {% for c in candidates.values() %} + + {% endfor %} + + +
+ {{ c.display or c.date }} +
+ {{ v.short or v.email }} + + {{ preference[c.id][v.email] }} + + {{ voter_max[v.email] }} +
+ {{ candidate_sum[c.id] }} +
+ + + diff --git a/templates/date_vote_form.html b/templates/date_vote_form.html new file mode 100644 index 0000000..36a4792 --- /dev/null +++ b/templates/date_vote_form.html @@ -0,0 +1,10 @@ +{% for d in dates %} +
+ +
+ 😞 + + 😀 +
+
+{% endfor %} diff --git a/templates/date_vote_fragment.html b/templates/date_vote_fragment.html index d4645f1..c292afb 100644 --- a/templates/date_vote_fragment.html +++ b/templates/date_vote_fragment.html @@ -1,14 +1,2 @@ - {% for d in dates %} -
- {{ loop.index }}. {{ d.display or d.date }} - -
- {% endfor %} -
- Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): - {% for r in result %} -
- {{ loop.index }}. {{ r.display or r.date }} -
- {% endfor %} -
+{% include "date_vote_form.html" %} +{% include "date_result_fragment.html" %} diff --git a/templates/place_result_fragment.html b/templates/place_result_fragment.html index 4b8823e..195a933 100644 --- a/templates/place_result_fragment.html +++ b/templates/place_result_fragment.html @@ -1,8 +1,10 @@
- Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): - {% for r in result %} -
- {{ loop.index }}. {{ r.name }} -
- {% endfor %} +

Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): +

    + {% for r in result %} +
  1. + {{ r.name }} ({{r.preference}}) +
  2. + {% endfor %} +
diff --git a/templates/place_result_matrix.html b/templates/place_result_matrix.html new file mode 100644 index 0000000..a6f8786 --- /dev/null +++ b/templates/place_result_matrix.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + {% for c in candidates.values() %} + + {% endfor %} + + {% for v in voters.values() %} + + + {% for c in candidates.values() %} + + {% endfor %} + + + {% endfor %} + + + {% for c in candidates.values() %} + + {% endfor %} + + +
+ {{ c.name }} +
+ {{ v.short or v.email }} + + {{ preference[c.id][v.email] }} + + {{ voter_max[v.email] }} +
+ {{ candidate_sum[c.id] }} +
+ + + diff --git a/templates/place_vote_form.html b/templates/place_vote_form.html new file mode 100644 index 0000000..412a417 --- /dev/null +++ b/templates/place_vote_form.html @@ -0,0 +1,10 @@ +{% for d in places %} +
+ +
+ 😞 + + 😀 +
+
+{% endfor %} diff --git a/templates/place_vote_fragment.html b/templates/place_vote_fragment.html index 7d15936..899949e 100644 --- a/templates/place_vote_fragment.html +++ b/templates/place_vote_fragment.html @@ -1,14 +1,2 @@ - {% for d in places %} -
- {{ loop.index }}. {{ d.name }} - -
- {% endfor %} -
- Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): - {% for r in result %} -
- {{ loop.index }}. {{ r.name }} -
- {% endfor %} -
+{% include "place_vote_form.html" %} +{% include "place_result_fragment.html" %} diff --git a/templates/time_result_fragment.html b/templates/time_result_fragment.html index 35368ff..a638f4e 100644 --- a/templates/time_result_fragment.html +++ b/templates/time_result_fragment.html @@ -1,8 +1,10 @@
- Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): +

Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): +

    {% for r in result %} -
    - {{ loop.index }}. {{ r.display or r.time }} -
    +
  1. + {{ r.display or r.time }} ({{r.preference}}) +
  2. {% endfor %} +
diff --git a/templates/time_result_matrix.html b/templates/time_result_matrix.html new file mode 100644 index 0000000..e6417d0 --- /dev/null +++ b/templates/time_result_matrix.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + {% for c in candidates.values() %} + + {% endfor %} + + {% for v in voters.values() %} + + + {% for c in candidates.values() %} + + {% endfor %} + + + {% endfor %} + + + {% for c in candidates.values() %} + + {% endfor %} + + +
+ {{ c.display or c.time }} +
+ {{ v.short or v.email }} + + {{ preference[c.id][v.email] }} + + {{ voter_max[v.email] }} +
+ {{ candidate_sum[c.id] }} +
+ + + diff --git a/templates/time_vote_form.html b/templates/time_vote_form.html new file mode 100644 index 0000000..747f16c --- /dev/null +++ b/templates/time_vote_form.html @@ -0,0 +1,10 @@ +{% for d in times %} +
+ +
+ 😞 + + 😀 +
+
+{% endfor %} diff --git a/templates/time_vote_fragment.html b/templates/time_vote_fragment.html index 6e35d9c..576fc44 100644 --- a/templates/time_vote_fragment.html +++ b/templates/time_vote_fragment.html @@ -1,14 +1,2 @@ - {% for d in times %} -
- {{ loop.index }}. {{ d.display or d.time }} - -
- {% endfor %} -
- Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}): - {% for r in result %} -
- {{ loop.index }}. {{ r.display or r.time }} -
- {% endfor %} -
+{% include "time_vote_form.html" %} +{% include "time_result_fragment.html" %} diff --git a/templates/vote.html b/templates/vote.html index 4cf509f..cf41793 100644 --- a/templates/vote.html +++ b/templates/vote.html @@ -4,7 +4,6 @@ - @@ -21,74 +20,43 @@ {{ voter.email }}{% if not loop.last %},{% endif %} {% endfor %}

+

An welchem Tag? - [details] + [details]

- Ordne die Optionen nach Präferenz: -
- {% for d in dates %} -
- {{ loop.index }}. {{ d.display or d.date }} - -
- {% endfor %} + + {% include "date_vote_form.html" %}
+

Zu welcher Zeit? - [details] + [details]

- Ordne die Optionen nach Präferenz: -
- {% for d in times %} -
- {{ loop.index }}. {{ d.display or d.time }} - -
- {% endfor %} + + {% include "time_vote_form.html" %}
+

An welchem Ort? - [details] + [details]

- Ordne die Optionen nach Präferenz: -
- {% for d in places %} -
- {{ loop.index }}. {{ d.name }} - -
- {% endfor %} + + {% include "place_vote_form.html" %}
-