Implement basic voting

This commit is contained in:
Peter J. Holzer 2022-11-05 09:51:32 +01:00
commit 65a19eb756
18 changed files with 537 additions and 0 deletions

20
Notes Normal file
View File

@ -0,0 +1,20 @@
Login (if necessary)
User is redirected to a form, enters email, mail is sent. User clicks on
confirmation link. Is authenticated and redirected to original URL
List of Votes
Do we need a meet-bod table or just show votes that the bod has
participated in? Go for the latter right now. We can send out
invites to meets
A vote
shows dates, times and places (in your preferential order if you
already voted). Plus current tally. Plus add button for date, time,
place
Actions:
reorder
add
That's it for now? I can add a new vote and close a vote in the db.
Or add a simple page for that.

247
app.py Normal file
View File

@ -0,0 +1,247 @@
import datetime
import email.message
import logging
import os
import smtplib
import psycopg
import psycopg.rows
from flask import (
Flask, session, redirect, url_for, request, render_template,
g, abort
)
import config
logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s %(lineno)d | %(message)s", level=logging.DEBUG)
app = Flask(__name__)
app.secret_key = config.secret_key
app.config["PERMANENT_SESSION_LIFETIME"] = datetime.timedelta(days=92)
log = logging.getLogger(__name__)
@app.route("/")
def home():
log.debug("in home")
log.debug("session = %s", session)
if "user" not in session:
return redirect(url_for('register'))
return render_template("home.html")
@app.route("/register", methods=["GET", "POST"])
def register():
log.debug("in register")
if request.method == "GET":
return render_template("register.html")
email_address = request.form.get("email", "")
if "@" not in email_address:
flash("Das schaut nicht wie eine E-Mail-Adresse aus")
csr = get_cursor()
csr.execute("select * from bod where email = %s", (email_address,))
r = csr.fetchone()
if r:
if r.key:
key = r.key
else:
key = os.urandom(8).hex()
csr.execute(
"update bod set key = %s, keychange = now() where email = %s",
(key, email_address,))
else:
key = os.urandom(8).hex()
csr.execute(
"insert into bod(email, key, keychange) values(%s, %s, now())",
(email_address, key,))
confirmation_url = url_for("confirm",
target=request.form["target"],
key=key)
send_mail(email_address, confirmation_url)
return render_template("wait_for_confirmation.html")
@app.route("/confirm")
def confirm():
csr = get_cursor()
csr.execute("select * from bod where key = %s", (request.args["key"],))
bod = csr.fetchone()
if not bod:
return render_template("wrong_key.html")
session["user"] = { "id": bod.id, "email": bod.email}
return redirect(request.args["target"])
@app.route("/vote/<string:key>")
def vote(key):
log.debug("session = %s", session)
csr = get_cursor()
csr.execute("select * from meet where key = %s", (key,))
meet = csr.fetchone()
if not meet:
abort(404)
csr.execute(
"""
select d.id, d.date, d.display, position
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
""",
(session["user"]["id"], meet.id,))
dates = csr.fetchall()
csr.execute(
"""
select d.id, d.time, d.display, position
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
""",
(session["user"]["id"], meet.id,))
times = csr.fetchall()
csr.execute(
"""
select d.id, d.name, position
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
""",
(session["user"]["id"], meet.id,))
places = csr.fetchall()
return render_template("vote.html",
meet=meet, 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")
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)", (date_ids,))
for pos, date_id in enumerate(date_ids):
csr.execute(
"insert into date_vote(date, bod, position) values(%s, %s, %s)",
(date_id, session["user"]["id"], pos)
)
# XXX - (almost) duplicate
csr.execute(
"""
select d.id, d.date, d.display, position
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
""",
(session["user"]["id"], meet_id,))
dates = csr.fetchall()
return render_template("date_vote_fragment.html", dates=dates)
@app.post("/vote/time")
def vote_time():
log.debug("form = %s", request.form)
time_ids = request.form.getlist("time")
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)", (time_ids,))
for pos, time_id in enumerate(time_ids):
csr.execute(
"insert into time_vote(time, bod, position) values(%s, %s, %s)",
(time_id, session["user"]["id"], pos)
)
# XXX - (almost) duplicate
csr.execute(
"""
select d.id, d.time, d.display, position
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
""",
(session["user"]["id"], meet_id,))
times = csr.fetchall()
return render_template("time_vote_fragment.html", times=times)
@app.post("/vote/place")
def vote_place():
log.debug("form = %s", request.form)
place_ids = request.form.getlist("place")
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)", (place_ids,))
for pos, place_id in enumerate(place_ids):
csr.execute(
"insert into place_vote(place, bod, position) values(%s, %s, %s)",
(place_id, session["user"]["id"], pos)
)
# XXX - (almost) duplicate
csr.execute(
"""
select d.id, d.name, position
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
""",
(session["user"]["id"], meet_id,))
places = csr.fetchall()
return render_template("place_vote_fragment.html", places=places)
def send_mail(email_address, confirmation_url):
msg = email.message.EmailMessage()
msg["From"] ="noreply@hjp.at"
msg["To"] = email_address
msg["Subject"] = "MEEAT confirmation"
body = confirmation_url # Really minimalistic
msg.set_content(body)
mta = smtplib.SMTP(host="localhost")
mta.send_message(msg)
def get_cursor():
db = get_db()
csr = db.cursor(row_factory=psycopg.rows.namedtuple_row)
return csr
def get_db():
if "db" not in g:
g.db = psycopg.connect(dbname=config.dbname)
return g.db
@app.teardown_appcontext
def teardown_db(exception):
log.debug("in teardown_db: exception = %s", exception)
db = g.pop('db', None)
if db is not None:
if not exception:
try:
db.commit()
except:
pass
db.rollback()
db.close()

42
meeat.procrusql Normal file
View File

@ -0,0 +1,42 @@
table meet
column meet id serial primary key
column meet title text not null
column meet key text unique
table date
column date id serial primary key
column date date date not null
column date display text
column date meet int references meet not null
table time
column time id serial primary key
column time time time not null
column time display text
column time meet int references meet not null
table place
column place id serial primary key
column place name text not null
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 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
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
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

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
flask
procrusql
psycopg
psycopg2

2
static/Sortable.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/fork-and-spoon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

71
static/fork-and-spoon.svg Normal file
View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 8.4666665 8.4666669"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="fork-and-spoon.svg"
inkscape:export-filename="/home/hjp/wrk/meeat/static/fork-and-spoon.png"
inkscape:export-xdpi="192"
inkscape:export-ydpi="192"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="true"
units="px"
width="32px"
inkscape:zoom="32.78125"
inkscape:cx="15.984747"
inkscape:cy="14.932316"
inkscape:window-width="1802"
inkscape:window-height="1327"
inkscape:window-x="-4"
inkscape:window-y="43"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid824"
empspacing="8" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;stroke:#000000;stroke-width:1.05833334;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 2.1166666,1.0583333 V 7.4083332"
id="path960" />
<path
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 0.52916667,1.0583333 -10e-9,2.1166667 3.17500004,0 V 1.0583333"
id="path1028"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 6.35,3.175 -10e-8,4.2333332"
id="path1424"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 5.0270833,2.1166666 c 1e-7,-0.7937499 0.5291667,-1.0583333 1.3229166,-1.0583333 0.7937501,0 1.3229168,0.2645834 1.3229167,1.0583333 C 7.6729167,2.9104167 6.8791667,3.175 6.3499999,3.175 5.55625,3.175 5.0270834,2.9104167 5.0270833,2.1166666 Z"
id="path1492"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

1
static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

13
static/style.css Normal file
View File

@ -0,0 +1,13 @@
body {
font-family: sans;
}
.sortable {
}
.sort-item {
padding: 1em;
border: #CCC 1px solid;
border-radius: 0.2em;
}

View File

@ -0,0 +1,6 @@
{% for d in dates %}
<div class="sort-item">
{{ d.display or d.date }}
<input type="hidden" name="date" value="{{d.id}}">
</div>
{% endfor %}

19
templates/home.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>Hallo, {{ session.user.email }}!</p>
<p>
{{ session }}
</p>
<p>
{{ session.user }}
</p>
<p>
{{ session.user.1 }}
</p>
</body>
</html>

View File

@ -0,0 +1,6 @@
{% for d in places %}
<div class="sort-item">
{{ d.name }}
<input type="hidden" name="place" value="{{d.id}}">
</div>
{% endfor %}

15
templates/register.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>Registriere dich:</p>
<form method="POST">
<input type="email" name="email">
<input name="target" value="{{request.args.target}}">
<input type="submit">
</form>
</body>
</html>

View File

@ -0,0 +1,6 @@
{% for d in times %}
<div class="sort-item">
{{ d.display or d.time }}
<input type="hidden" name="time" value="{{d.id}}">
</div>
{% endfor %}

63
templates/vote.html Normal file
View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="/static/htmx.min.js"></script>
<script src="/static/Sortable.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<p>Hallo, {{ session.user.email }}!</p>
<p>
{{ meet.title }}
</p>
<p>
An welchem Tag?
</p>
<form class="sortable" hx-post="/vote/date" hx-trigger="end">
{% for d in dates %}
<div class="sort-item">
{{ d.display or d.date }}
<input type="hidden" name="date" value="{{d.id}}">
</div>
{% endfor %}
</form>
<p>
Zu welcher Zeit?
</p>
<form class="sortable" hx-post="/vote/time" hx-trigger="end">
{% for d in times %}
<div class="sort-item">
{{ d.display or d.time }}
<input type="hidden" name="time" value="{{d.id}}">
</div>
{% endfor %}
</form>
<p>
An welchem Ort?
</p>
<form class="sortable" hx-post="/vote/place" hx-trigger="end">
{% for d in places %}
<div class="sort-item">
{{ d.name }}
<input type="hidden" name="place" value="{{d.id}}">
</div>
{% endfor %}
</form>
</body>
<script>
htmx.onLoad(function(content) {
var sortables = document.querySelectorAll(".sortable");
for (const sortable of sortables) {
console.debug("making", sortable, "sortable")
new Sortable(sortable, {
animation: 150,
ghostClass: 'blue-background-class'
});
}
})
</script>
</html>

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>Confirmation-Mail gesendet</p>
</body>
</html>

11
templates/wrong_key.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>Key nicht gefunden. Abgelaufen?</p>
<p><a href="{{url_for('register')}}">Zur Registrierung</a></p>
</body>
</html>