Merge branch 'range'

This commit is contained in:
Peter J. Holzer 2024-10-15 01:21:09 +02:00
commit 47d318782b
16 changed files with 355 additions and 265 deletions

265
app.py
View File

@ -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,27 +112,27 @@ 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()
@ -141,39 +143,30 @@ def vote(key):
@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/<int:meet_id>/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/<int:meet_id>/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/<int:meet_id>/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/<int:meet_id>/date/ballot")
def result_ballots_date(meet_id):
ballots = get_ballots(meet_id, "date")
result = instantrunoff_backward(meet_id, "date")
@app.get("/result/<int:meet_id>/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/<int:meet_id>/time/ballot")
def result_ballots_time(meet_id):
ballots = get_ballots(meet_id, "time")
result = instantrunoff_backward(meet_id, "time")
@app.get("/result/<int:meet_id>/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/<int:meet_id>/place/ballot")
def result_ballots_place(meet_id):
ballots = get_ballots(meet_id, "place")
result = instantrunoff_backward(meet_id, "place")
@app.get("/result/<int:meet_id>/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)
voters = {}
voter_max = collections.defaultdict(float)
candidate_sum = collections.defaultdict(float)
preference = collections.defaultdict(dict)
for r in csr:
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[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()

View File

@ -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

View File

@ -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;
}

View File

@ -1,8 +1,10 @@
<div id="r-day" hx-swap-oob="true">
Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
<p>Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
<ol class="result-items">
{% for r in result %}
<div class="result-item">
{{ loop.index }}. {{ r.display or r.date }}
</div>
<li class="result-item">
{{ r.display or r.date }} ({{r.preference}})
</li>
{% endfor %}
</ol>
</div>

View File

@ -0,0 +1,47 @@
<!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>
<table class="matrix">
<tr>
<th></th>
{% for c in candidates.values() %}
<th>
{{ c.display or c.date }}
</th>
{% endfor %}
</tr>
{% for v in voters.values() %}
<tr>
<th>
{{ v.short or v.email }}
</th>
{% for c in candidates.values() %}
<td>
{{ preference[c.id][v.email] }}
</td>
{% endfor %}
<td>
{{ voter_max[v.email] }}
</td>
</tr>
{% endfor %}
<tr>
<th></th>
{% for c in candidates.values() %}
<td>
{{ candidate_sum[c.id] }}
</td>
{% endfor %}
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,10 @@
{% for d in dates %}
<div class="vote-item">
<label for="date_{{d.id}}">{{ d.display or d.date }}</label>
<div>
<span class="emoji">😞</span>
<input type="range" name="date_{{d.id}}" value="{{d.preference}}">
<span class="emoji">😀</span>
</div>
</div>
{% endfor %}

View File

@ -1,14 +1,2 @@
{% for d in dates %}
<div class="sort-item">
{{ loop.index }}. {{ d.display or d.date }}
<input type="hidden" name="date" value="{{d.id}}">
</div>
{% endfor %}
<div id="r-day" hx-swap-oob="true">
Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
{% for r in result %}
<div class="result-item">
{{ loop.index }}. {{ r.display or r.date }}
</div>
{% endfor %}
</div>
{% include "date_vote_form.html" %}
{% include "date_result_fragment.html" %}

View File

@ -1,8 +1,10 @@
<div id="r-place" hx-swap-oob="true">
Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
<p>Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
<ol class="result-items">
{% for r in result %}
<div class="result-item">
{{ loop.index }}. {{ r.name }}
</div>
<li class="result-item">
{{ r.name }} ({{r.preference}})
</li>
{% endfor %}
</ol>
</div>

View File

@ -0,0 +1,47 @@
<!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>
<table class="matrix">
<tr>
<th></th>
{% for c in candidates.values() %}
<th>
{{ c.name }}
</th>
{% endfor %}
</tr>
{% for v in voters.values() %}
<tr>
<th>
{{ v.short or v.email }}
</th>
{% for c in candidates.values() %}
<td>
{{ preference[c.id][v.email] }}
</td>
{% endfor %}
<td>
{{ voter_max[v.email] }}
</td>
</tr>
{% endfor %}
<tr>
<th></th>
{% for c in candidates.values() %}
<td>
{{ candidate_sum[c.id] }}
</td>
{% endfor %}
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,10 @@
{% for d in places %}
<div class="vote-item">
<label for="place_{{d.id}}">{{ d.name }}</label>
<div>
<span class="emoji">😞</span>
<input type="range" name="place_{{d.id}}" value="{{d.preference}}">
<span class="emoji">😀</span>
</div>
</div>
{% endfor %}

View File

@ -1,14 +1,2 @@
{% for d in places %}
<div class="sort-item">
{{ loop.index }}. {{ d.name }}
<input type="hidden" name="place" value="{{d.id}}">
</div>
{% endfor %}
<div id="r-place" hx-swap-oob="true">
Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
{% for r in result %}
<div class="result-item">
{{ loop.index }}. {{ r.name }}
</div>
{% endfor %}
</div>
{% include "place_vote_form.html" %}
{% include "place_result_fragment.html" %}

View File

@ -1,8 +1,10 @@
<div id="r-time" hx-swap-oob="true">
Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
<p>Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
<ol class="result-items">
{% for r in result %}
<div class="result-item">
{{ loop.index }}. {{ r.display or r.time }}
</div>
<li class="result-item">
{{ r.display or r.time }} ({{r.preference}})
</li>
{% endfor %}
</ol>
</div>

View File

@ -0,0 +1,47 @@
<!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>
<table class="matrix">
<tr>
<th></th>
{% for c in candidates.values() %}
<th>
{{ c.display or c.time }}
</th>
{% endfor %}
</tr>
{% for v in voters.values() %}
<tr>
<th>
{{ v.short or v.email }}
</th>
{% for c in candidates.values() %}
<td>
{{ preference[c.id][v.email] }}
</td>
{% endfor %}
<td>
{{ voter_max[v.email] }}
</td>
</tr>
{% endfor %}
<tr>
<th></th>
{% for c in candidates.values() %}
<td>
{{ candidate_sum[c.id] }}
</td>
{% endfor %}
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,10 @@
{% for d in times %}
<div class="vote-item">
<label for="time_{{d.id}}">{{ d.display or d.time }}</label>
<div>
<span class="emoji">😞</span>
<input type="range" name="time_{{d.id}}" value="{{d.preference}}">
<span class="emoji">😀</span>
</div>
</div>
{% endfor %}

View File

@ -1,14 +1,2 @@
{% for d in times %}
<div class="sort-item">
{{ loop.index }}. {{ d.display or d.time }}
<input type="hidden" name="time" value="{{d.id}}">
</div>
{% endfor %}
<div id="r-time" hx-swap-oob="true">
Ergebnis ({% for v in voters %}{{ v.short or v.email }}{% if not loop.last %}, {% endif %}{% endfor %}):
{% for r in result %}
<div class="result-item">
{{ loop.index }}. {{ r.display or r.time }}
</div>
{% endfor %}
</div>
{% include "time_vote_form.html" %}
{% include "time_result_fragment.html" %}

View File

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title> MEEAT ⋄ {{ meet.title }} </title>
<script src="/static/htmx.min.js"></script>
<script src="/static/Sortable.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
@ -21,74 +21,43 @@
{{ voter.email }}{% if not loop.last %},{% endif %}
{% endfor %}
</p>
<h2 id="h-day">
An welchem Tag?
<a href="/result/{{meet.id}}/date/ballot">[details]</a>
<a href="/result/{{meet.id}}/date/matrix">[details]</a>
</h2>
<div>
Ordne die Optionen nach Präferenz:
<form id="v-day" class="sortable" hx-post="/vote/date" hx-trigger="end">
{% for d in dates %}
<div class="sort-item">
{{ loop.index }}. {{ d.display or d.date }}
<input type="hidden" name="date" value="{{d.id}}">
</div>
{% endfor %}
<form id="v-day" hx-post="/vote/date" hx-trigger="change">
{% include "date_vote_form.html" %}
</form>
</div>
<div id="r-day" hx-get="/result/{{meet.id}}/date" hx-trigger="load">
</div>
<h2 id="h-time">
Zu welcher Zeit?
<a href="/result/{{meet.id}}/time/ballot">[details]</a>
<a href="/result/{{meet.id}}/time/matrix">[details]</a>
</h2>
<div>
Ordne die Optionen nach Präferenz:
<form id="v-time" class="sortable" hx-post="/vote/time" hx-trigger="end">
{% for d in times %}
<div class="sort-item">
{{ loop.index }}. {{ d.display or d.time }}
<input type="hidden" name="time" value="{{d.id}}">
</div>
{% endfor %}
<form id="v-time" hx-post="/vote/time" hx-trigger="change">
{% include "time_vote_form.html" %}
</form>
</div>
<div id="r-time" hx-get="/result/{{meet.id}}/time" hx-trigger="load">
</div>
<h2 id="h-place">
An welchem Ort?
<a href="/result/{{meet.id}}/place/ballot">[details]</a>
<a href="/result/{{meet.id}}/place/matrix">[details]</a>
</h2>
<div>
Ordne die Optionen nach Präferenz:
<form id="v-place" class="sortable" hx-post="/vote/place" hx-trigger="end">
{% for d in places %}
<div class="sort-item">
{{ loop.index }}. {{ d.name }}
<input type="hidden" name="place" value="{{d.id}}">
</div>
{% endfor %}
<form id="v-place" hx-post="/vote/place" hx-trigger="change">
{% include "place_vote_form.html" %}
</form>
</div>
<div id="r-place" hx-get="/result/{{meet.id}}/place" hx-trigger="load">
</div>
</div>
</body>
<script>
function activateSortables(element) {
var sortables = document.querySelectorAll(".sortable");
for (const sortable of sortables) {
console.debug("making", sortable, "sortable")
new Sortable(sortable, {
animation: 150,
ghostClass: 'blue-background-class'
});
}
}
activateSortables(document)
//htmx.onLoad(function(content) { activateSortables(content) }
</script>
</html>