support for observers
This commit is contained in:
11
README.md
11
README.md
@@ -12,8 +12,7 @@
|
|||||||
- Command line tools python3
|
- Command line tools python3
|
||||||
|
|
||||||
|
|
||||||
|
## Basic usage instructions
|
||||||
## Usage instructions
|
|
||||||
|
|
||||||
- Install requirements `./install.me`
|
- Install requirements `./install.me`
|
||||||
- Start server `./start.me`
|
- Start server `./start.me`
|
||||||
@@ -26,4 +25,12 @@
|
|||||||
- Open the link to vote
|
- Open the link to vote
|
||||||
- Get summary of votes: `./manager summary my_poll`
|
- Get summary of votes: `./manager summary my_poll`
|
||||||
|
|
||||||
|
## Additional usage
|
||||||
|
|
||||||
|
During the poll, you can observe the vote counts:
|
||||||
|
- Add an observer token:
|
||||||
|
`./manager token --prefix http://localhost:8041 --role observer my_poll`
|
||||||
|
- Open the link to see vote counts
|
||||||
|
- If the question se allows voters to see results after casting their vote
|
||||||
|
`show_results: true`, then observer can also see the results.
|
||||||
|
|
||||||
|
|||||||
47
abot.py
47
abot.py
@@ -1,33 +1,39 @@
|
|||||||
|
|
||||||
import sqlite3
|
from flask import Flask, request, g, url_for, render_template
|
||||||
from flask import Flask, request, g, url_for, \
|
|
||||||
render_template
|
|
||||||
from revprox import ReverseProxied
|
from revprox import ReverseProxied
|
||||||
from utils import *
|
from utils import *
|
||||||
import manager
|
import manager
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
DATABASE = 'abot.sqlite' # database file
|
|
||||||
DEBUG = True
|
DATABASE = os.getenv('DATABASE', 'abot.sqlite') # database file
|
||||||
QUESTIONS = 'questions' # path to questions
|
DEBUG = False
|
||||||
|
QUESTIONS = os.getenv('QUESTIONS', 'questions') # path to questions
|
||||||
|
GIT_COMMIT = os.getenv('GIT_COMMIT', False)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(__name__)
|
app.config.from_object(__name__)
|
||||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def before_request():
|
def before_request():
|
||||||
g.db = connect_db()
|
g.db = connect_db()
|
||||||
|
|
||||||
|
|
||||||
@app.teardown_request
|
@app.teardown_request
|
||||||
def teardown_request(exception):
|
def teardown_request(exception):
|
||||||
db = getattr(g, 'db', None)
|
db = getattr(g, 'db', None)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/preview/<key>')
|
@app.route('/preview/<key>')
|
||||||
def preview(key):
|
def preview(key):
|
||||||
if not is_key(key):
|
if not is_key(key):
|
||||||
@@ -45,6 +51,7 @@ def preview(key):
|
|||||||
valid_for = valid_for
|
valid_for = valid_for
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/vote/<key>/<token>')
|
@app.route('/vote/<key>/<token>')
|
||||||
@app.route('/vote/<key>')
|
@app.route('/vote/<key>')
|
||||||
def vote(key, token = None):
|
def vote(key, token = None):
|
||||||
@@ -64,6 +71,7 @@ def vote(key, token = None):
|
|||||||
|
|
||||||
return render_template('vote.html', form = form, key = key, token = token, valid_for = valid_for)
|
return render_template('vote.html', form = form, key = key, token = token, valid_for = valid_for)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/save', methods=['POST'])
|
@app.route('/save', methods=['POST'])
|
||||||
def save_vote():
|
def save_vote():
|
||||||
key = request.form['key']
|
key = request.form['key']
|
||||||
@@ -99,6 +107,33 @@ def save_vote():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/observe/<key>/<token>')
|
||||||
|
def observe(key, token):
|
||||||
|
if not is_key(key):
|
||||||
|
return render_template('blank.html', message = "Unknown key")
|
||||||
|
if not is_observer(key, token):
|
||||||
|
return render_template('blank.html', message = "Token not valid")
|
||||||
|
|
||||||
|
tokens = get_token_counts(g.db, key)
|
||||||
|
summary = False
|
||||||
|
questions = []
|
||||||
|
answers = []
|
||||||
|
form = parse_form(key)
|
||||||
|
if not form:
|
||||||
|
return render_template('blank.html', message = "Error creating form")
|
||||||
|
if is_show_results(form):
|
||||||
|
summary = True
|
||||||
|
questions, answers = sort_summary(*get_summary(g.db, key))
|
||||||
|
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'observe.html',
|
||||||
|
summary = summary,
|
||||||
|
tokens = tokens,
|
||||||
|
qa = zip(questions, answers)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
create_db(DATABASE)
|
create_db(DATABASE)
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
manager.main(DATABASE, QUESTIONS)
|
manager.main(DATABASE, QUESTIONS)
|
||||||
|
|||||||
31
manager.py
31
manager.py
@@ -10,19 +10,21 @@ import string
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def insert_token(db, name, token):
|
def insert_token(db, name, token, role):
|
||||||
cur = db.cursor()
|
cur = db.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO tokens (token, question_set, answered) VALUES (
|
INSERT INTO tokens (token, question_set, answered, role) VALUES (
|
||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
'false'
|
'false',
|
||||||
|
?
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
get_hash(token),
|
get_hash(token),
|
||||||
name
|
name,
|
||||||
|
role
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -35,7 +37,7 @@ def manage_tokens(options):
|
|||||||
if options.list:
|
if options.list:
|
||||||
cur = db.cursor()
|
cur = db.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT token, answered FROM tokens WHERE question_set = ?",
|
"SELECT token, answered FROM tokens WHERE question_set = ? AND role = 'voter'",
|
||||||
(
|
(
|
||||||
options.name,
|
options.name,
|
||||||
)
|
)
|
||||||
@@ -47,12 +49,17 @@ def manage_tokens(options):
|
|||||||
"used" if row[1] == "true" else "unused"
|
"used" if row[1] == "true" else "unused"
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
if options.role == 'voter':
|
||||||
|
service = 'vote'
|
||||||
|
if options.role == 'observer':
|
||||||
|
service = 'observe'
|
||||||
for i in range(options.number):
|
for i in range(options.number):
|
||||||
N = 32
|
N = 32
|
||||||
token = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(N))
|
token = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(N))
|
||||||
insert_token(db, options.name, token)
|
insert_token(db, options.name, token, options.role)
|
||||||
print("%s/vote/%s/%s"%(
|
print("%s/%s/%s/%s"%(
|
||||||
options.prefix,
|
options.prefix,
|
||||||
|
service,
|
||||||
options.name,
|
options.name,
|
||||||
token
|
token
|
||||||
))
|
))
|
||||||
@@ -95,6 +102,14 @@ def parse_options(database, questions):
|
|||||||
default = "",
|
default = "",
|
||||||
help = "Prefix tokens with the server URL to automate emails etc.."
|
help = "Prefix tokens with the server URL to automate emails etc.."
|
||||||
)
|
)
|
||||||
|
parser_token.add_argument(
|
||||||
|
'--role',
|
||||||
|
action = "store",
|
||||||
|
dest = "role",
|
||||||
|
default = "voter",
|
||||||
|
choices = ['voter', 'observer'],
|
||||||
|
help = "Add token for role. observer is a token that can only view the current status of the vote event."
|
||||||
|
)
|
||||||
parser_token.add_argument(
|
parser_token.add_argument(
|
||||||
'--list', '-l',
|
'--list', '-l',
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -177,6 +192,7 @@ def summary(options):
|
|||||||
out = summary_list(questions, answers, tokens)
|
out = summary_list(questions, answers, tokens)
|
||||||
print(out)
|
print(out)
|
||||||
|
|
||||||
|
|
||||||
def summary_list(questions, answers, tokens):
|
def summary_list(questions, answers, tokens):
|
||||||
s = """# Tokens for this question set:
|
s = """# Tokens for this question set:
|
||||||
# used: {used}, unused: {unused}, total: {total}
|
# used: {used}, unused: {unused}, total: {total}
|
||||||
@@ -266,6 +282,7 @@ def clear_tokens(options):
|
|||||||
db.commit()
|
db.commit()
|
||||||
print("\nDeleted tokens for %s"%( options.name, ))
|
print("\nDeleted tokens for %s"%( options.name, ))
|
||||||
|
|
||||||
|
|
||||||
def main(database, questions):
|
def main(database, questions):
|
||||||
options = parse_options(database, questions)
|
options = parse_options(database, questions)
|
||||||
if options.subparser_name == "list":
|
if options.subparser_name == "list":
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
# expiry format: YYYY-MM-DD HH:MM +z
|
# expiry format: YYYY-MM-DD HH:MM +z
|
||||||
# z is the difference to UTC in HHMM, +0000, -0700, etc..
|
# z is the difference to UTC in HHMM, +0000, -0700, etc..
|
||||||
expires: 2028-12-12 21:20 +0200
|
expires: 2028-12-12 21:20 +0200
|
||||||
13
questions/examples/open_vote.txt
Normal file
13
questions/examples/open_vote.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
draft: false
|
||||||
|
vote_style: open
|
||||||
|
show_results: true
|
||||||
|
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/en/thumb/7/7d/Lenna_%28test_image%29.png/220px-Lenna_%28test_image%29.png"/>
|
||||||
|
<p>This questionnaire is about a very important topic</p>
|
||||||
|
|
||||||
|
It works?
|
||||||
|
- yes
|
||||||
|
- no
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
|
||||||
# if "true" voting is not possible. you can preview the form in address /preview/example
|
# if "true" voting is not possible. you can preview the form in address /preview/example
|
||||||
draft: false
|
draft: false
|
||||||
|
|
||||||
# "open" vote style is open for anyone without tokens. "closed" requires tokens to be generated
|
# "open" vote style is open for anyone without tokens. "closed" requires tokens to be generated
|
||||||
vote_style: open
|
vote_style: closed
|
||||||
|
|
||||||
# By default voters can not see the results
|
# By default voters can not see the results
|
||||||
show_results: true
|
show_results: false
|
||||||
|
|
||||||
# Questions are any line that doesnt match configuration commands
|
# Questions are any line that doesnt match configuration commands
|
||||||
It works?
|
It works?
|
||||||
1
start.me
1
start.me
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
cd $( dirname "$0" )
|
cd $( dirname "$0" )
|
||||||
. virtualenv/bin/activate
|
. virtualenv/bin/activate
|
||||||
|
test -f .git/refs/heads/master && export GIT_COMMIT=$( cat .git/refs/heads/master )
|
||||||
exec gunicorn -b 127.0.0.1:8041 -w 3 abot:app
|
exec gunicorn -b 127.0.0.1:8041 -w 3 abot:app
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ textarea {
|
|||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#counts {
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
}
|
||||||
|
#counts th, #counts td {
|
||||||
|
border: 3px solid #ccc;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
About aBot
|
About aBot
|
||||||
<ul>
|
<ul>
|
||||||
<li>This voting machine does not store information about you.</li>
|
<li>This voting machine does not store information about you.</li>
|
||||||
<li>The token given to you is stored separately to the answers you give.</li>
|
<li>The token given to you is stored separately from the answers you give.</li>
|
||||||
<li>If you were given the token via email or any other such means, this voting machine does not know the connection between your contact information and the vote token.</li>
|
<li>This voting machine does not know the connection between your contact information and the vote token. Somebody else handled the token emailing (or any other kind of message).</li>
|
||||||
<li>Source code at <a href="https://bitbucket.org/MoonQ/aBot/">Bitbucket</a></li>
|
<li>Source code at <a href="https://bitbucket.org/MoonQ/aBot/">Bitbucket</a>.
|
||||||
|
{% if config.GIT_COMMIT %} Last known changeset in the running environment: {{ config.GIT_COMMIT }}{% endif %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a id="bottom"></a>
|
<a id="bottom"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
Thank you for the vote!
|
Thank you for the vote!
|
||||||
</div>
|
</div>
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
<h3>Current report</h3>
|
<h3>Current results</h3>
|
||||||
|
|
||||||
<div id="questions">
|
<div id="questions">
|
||||||
{% for qa_item in qa %}
|
{% for qa_item in qa %}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
{% include "questions.html" %}
|
{% include "questions.html" %}
|
||||||
<p>
|
<p>
|
||||||
<input type=submit name=submit /><br>
|
<input type=submit name=submit /><br>
|
||||||
<span class = "warning">Votes can not be edited later!</span>
|
<div class = "warning">
|
||||||
|
You can only vote once!
|
||||||
|
<ul>
|
||||||
|
<li>Votes can not be edited later</li>
|
||||||
|
<li>Empty choices counts as empty, used vote</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
36
utils.py
36
utils.py
@@ -1,11 +1,12 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import g
|
from flask import g
|
||||||
import os
|
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
import html
|
|
||||||
import sqlite3
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
def connect_db():
|
def connect_db():
|
||||||
return sqlite3.connect(app.config['DATABASE'])
|
return sqlite3.connect(app.config['DATABASE'])
|
||||||
@@ -27,27 +28,30 @@ def create_db(db_file):
|
|||||||
CREATE TABLE IF NOT EXISTS tokens (
|
CREATE TABLE IF NOT EXISTS tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
question_set TEXT,
|
question_set TEXT,
|
||||||
answered BOOLEAN
|
answered BOOLEAN,
|
||||||
|
role TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def get_hash(s):
|
def get_hash(s):
|
||||||
return hashlib.sha224(s.encode('utf-8')).hexdigest()
|
return hashlib.sha224(s.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_token_counts(db, key):
|
def get_token_counts(db, key):
|
||||||
cur = db.cursor()
|
cur = db.cursor()
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT count(*) FROM tokens WHERE answered = 'true' and question_set = ?",
|
"SELECT count(*) FROM tokens WHERE answered = 'true' AND question_set = ? AND role = 'voter'",
|
||||||
(
|
(
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
used_tokens = cur.fetchall()[0][0]
|
used_tokens = cur.fetchall()[0][0]
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT count(*) FROM tokens WHERE answered = 'false' and question_set = ?",
|
"SELECT count(*) FROM tokens WHERE answered = 'false' AND question_set = ? AND role = 'voter'",
|
||||||
(
|
(
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
@@ -60,6 +64,7 @@ def get_token_counts(db, key):
|
|||||||
}
|
}
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
def get_summary(db, key):
|
def get_summary(db, key):
|
||||||
""" returns summary for a vote event """
|
""" returns summary for a vote event """
|
||||||
questions = []
|
questions = []
|
||||||
@@ -92,7 +97,21 @@ def has_voted(key, token):
|
|||||||
return True
|
return True
|
||||||
cur = g.db.cursor()
|
cur = g.db.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT token FROM tokens WHERE token = ? AND answered = 'true' AND question_set = ?",
|
"SELECT token FROM tokens WHERE token = ? AND answered = 'true' AND question_set = ? AND role = 'voter'",
|
||||||
|
(
|
||||||
|
get_hash(token),
|
||||||
|
key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return len(cur.fetchall()) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_observer(key, token):
|
||||||
|
if token == None:
|
||||||
|
return False
|
||||||
|
cur = g.db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT token FROM tokens WHERE token = ? AND question_set = ? AND role = 'observer'",
|
||||||
(
|
(
|
||||||
get_hash(token),
|
get_hash(token),
|
||||||
key
|
key
|
||||||
@@ -214,6 +233,7 @@ def parse_row_date(row):
|
|||||||
raise err
|
raise err
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def write_vote(key, token, answers, form):
|
def write_vote(key, token, answers, form):
|
||||||
|
|
||||||
cur = g.db.cursor()
|
cur = g.db.cursor()
|
||||||
@@ -253,6 +273,7 @@ def write_vote(key, token, answers, form):
|
|||||||
)
|
)
|
||||||
g.db.commit()
|
g.db.commit()
|
||||||
|
|
||||||
|
|
||||||
def sort_summary(questions, answers):
|
def sort_summary(questions, answers):
|
||||||
sorted_answer_list = []
|
sorted_answer_list = []
|
||||||
for q,a in zip(questions, answers):
|
for q,a in zip(questions, answers):
|
||||||
@@ -264,6 +285,7 @@ def sort_summary(questions, answers):
|
|||||||
sorted_answer_list.append(sorted_answers)
|
sorted_answer_list.append(sorted_answers)
|
||||||
return questions, sorted_answer_list
|
return questions, sorted_answer_list
|
||||||
|
|
||||||
|
|
||||||
def time_to_expiry(form):
|
def time_to_expiry(form):
|
||||||
if form['expires'] == None:
|
if form['expires'] == None:
|
||||||
return "Never"
|
return "Never"
|
||||||
|
|||||||
Reference in New Issue
Block a user