From cef7966ad2030241163c6c6db1ae0dd3eb439613 Mon Sep 17 00:00:00 2001 From: Ville Rantanen Date: Wed, 12 Dec 2018 22:09:12 +0200 Subject: [PATCH] support for observers --- README.md | 11 ++++- abot.py | 47 ++++++++++++++++++--- manager.py | 35 +++++++++++---- questions/{ => examples}/multi_question.txt | 1 - questions/examples/open_vote.txt | 13 ++++++ questions/{ => examples}/simple_example.txt | 7 +-- start.me | 1 + static/style.css | 9 ++++ templates/layout.html | 7 +-- templates/thank_you.html | 2 +- templates/vote.html | 8 +++- utils.py | 36 +++++++++++++--- 12 files changed, 144 insertions(+), 33 deletions(-) rename questions/{ => examples}/multi_question.txt (99%) create mode 100644 questions/examples/open_vote.txt rename questions/{ => examples}/simple_example.txt (90%) diff --git a/README.md b/README.md index cc409b3..f1784f6 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,7 @@ - Command line tools python3 - -## Usage instructions +## Basic usage instructions - Install requirements `./install.me` - Start server `./start.me` @@ -26,4 +25,12 @@ - Open the link to vote - 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. diff --git a/abot.py b/abot.py index 3145df8..73b9fab 100644 --- a/abot.py +++ b/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 utils import * import manager +import os +import sqlite3 -DATABASE = 'abot.sqlite' # database file -DEBUG = True -QUESTIONS = 'questions' # path to questions + +DATABASE = os.getenv('DATABASE', 'abot.sqlite') # database file +DEBUG = False +QUESTIONS = os.getenv('QUESTIONS', 'questions') # path to questions +GIT_COMMIT = os.getenv('GIT_COMMIT', False) app = Flask(__name__) app.config.from_object(__name__) app.wsgi_app = ReverseProxied(app.wsgi_app) + @app.before_request def before_request(): g.db = connect_db() + @app.teardown_request def teardown_request(exception): db = getattr(g, 'db', None) if db is not None: db.close() + @app.route('/') def index(): return render_template('index.html') + @app.route('/preview/') def preview(key): if not is_key(key): @@ -45,6 +51,7 @@ def preview(key): valid_for = valid_for ) + @app.route('/vote//') @app.route('/vote/') 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) + @app.route('/save', methods=['POST']) def save_vote(): key = request.form['key'] @@ -99,6 +107,33 @@ def save_vote(): ) +@app.route('/observe//') +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) if __name__ == "__main__": manager.main(DATABASE, QUESTIONS) diff --git a/manager.py b/manager.py index 127d44b..83c48a3 100644 --- a/manager.py +++ b/manager.py @@ -10,19 +10,21 @@ import string import sys -def insert_token(db, name, token): +def insert_token(db, name, token, role): cur = db.cursor() cur.execute(""" - INSERT INTO tokens (token, question_set, answered) VALUES ( + INSERT INTO tokens (token, question_set, answered, role) VALUES ( ?, ?, - 'false' + 'false', + ? ); """, ( get_hash(token), - name + name, + role ) ) db.commit() @@ -35,7 +37,7 @@ def manage_tokens(options): if options.list: cur = db.cursor() cur.execute( - "SELECT token, answered FROM tokens WHERE question_set = ?", + "SELECT token, answered FROM tokens WHERE question_set = ? AND role = 'voter'", ( options.name, ) @@ -47,12 +49,17 @@ def manage_tokens(options): "used" if row[1] == "true" else "unused" )) return + if options.role == 'voter': + service = 'vote' + if options.role == 'observer': + service = 'observe' for i in range(options.number): N = 32 token = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(N)) - insert_token(db, options.name, token) - print("%s/vote/%s/%s"%( + insert_token(db, options.name, token, options.role) + print("%s/%s/%s/%s"%( options.prefix, + service, options.name, token )) @@ -90,11 +97,19 @@ def parse_options(database, questions): ) parser_token.add_argument( '--prefix', - action="store", - dest="prefix", + action = "store", + dest = "prefix", default = "", 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( '--list', '-l', action="store_true", @@ -177,6 +192,7 @@ def summary(options): out = summary_list(questions, answers, tokens) print(out) + def summary_list(questions, answers, tokens): s = """# Tokens for this question set: # used: {used}, unused: {unused}, total: {total} @@ -266,6 +282,7 @@ def clear_tokens(options): db.commit() print("\nDeleted tokens for %s"%( options.name, )) + def main(database, questions): options = parse_options(database, questions) if options.subparser_name == "list": diff --git a/questions/multi_question.txt b/questions/examples/multi_question.txt similarity index 99% rename from questions/multi_question.txt rename to questions/examples/multi_question.txt index 6f60823..9ec2896 100644 --- a/questions/multi_question.txt +++ b/questions/examples/multi_question.txt @@ -1,4 +1,3 @@ - # expiry format: YYYY-MM-DD HH:MM +z # z is the difference to UTC in HHMM, +0000, -0700, etc.. expires: 2028-12-12 21:20 +0200 diff --git a/questions/examples/open_vote.txt b/questions/examples/open_vote.txt new file mode 100644 index 0000000..098417b --- /dev/null +++ b/questions/examples/open_vote.txt @@ -0,0 +1,13 @@ + +draft: false +vote_style: open +show_results: true + + +

This questionnaire is about a very important topic

+ +It works? +- yes +- no + + diff --git a/questions/simple_example.txt b/questions/examples/simple_example.txt similarity index 90% rename from questions/simple_example.txt rename to questions/examples/simple_example.txt index 96019cb..bbf8b12 100644 --- a/questions/simple_example.txt +++ b/questions/examples/simple_example.txt @@ -1,10 +1,11 @@ - # if "true" voting is not possible. you can preview the form in address /preview/example draft: false + # "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 -show_results: true +show_results: false # Questions are any line that doesnt match configuration commands It works? diff --git a/start.me b/start.me index 2b8fdf8..6f8b89f 100755 --- a/start.me +++ b/start.me @@ -1,4 +1,5 @@ #!/bin/bash cd $( dirname "$0" ) . 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 diff --git a/static/style.css b/static/style.css index 9456c73..a74dd88 100644 --- a/static/style.css +++ b/static/style.css @@ -63,3 +63,12 @@ textarea { margin-bottom: 1em; } +#counts { + border-collapse: collapse; + +} +#counts th, #counts td { + border: 3px solid #ccc; + text-align: center; + padding: 3px; +} diff --git a/templates/layout.html b/templates/layout.html index 490dcdd..80c7407 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -19,9 +19,10 @@ About aBot
  • This voting machine does not store information about you.
  • -
  • The token given to you is stored separately to the answers you give.
  • -
  • 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.
  • -
  • Source code at Bitbucket
  • +
  • The token given to you is stored separately from the answers you give.
  • +
  • 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).
  • +
  • Source code at Bitbucket. + {% if config.GIT_COMMIT %} Last known changeset in the running environment: {{ config.GIT_COMMIT }}{% endif %}
diff --git a/templates/thank_you.html b/templates/thank_you.html index d60d6da..61e7491 100644 --- a/templates/thank_you.html +++ b/templates/thank_you.html @@ -5,7 +5,7 @@ Thank you for the vote! {% if summary %} -

Current report

+

Current results

{% for qa_item in qa %} diff --git a/templates/vote.html b/templates/vote.html index 8d8f73a..6883c41 100644 --- a/templates/vote.html +++ b/templates/vote.html @@ -8,7 +8,13 @@ {% include "questions.html" %}


- Votes can not be edited later! +

+ You can only vote once! +
    +
  • Votes can not be edited later
  • +
  • Empty choices counts as empty, used vote
  • +
+

{% endblock %} diff --git a/utils.py b/utils.py index b56f66c..c7fe1d0 100644 --- a/utils.py +++ b/utils.py @@ -1,11 +1,12 @@ from datetime import datetime, timezone from flask import current_app as app from flask import g -import os from werkzeug.utils import secure_filename -import html -import sqlite3 import hashlib +import html +import os +import sqlite3 + def connect_db(): return sqlite3.connect(app.config['DATABASE']) @@ -27,27 +28,30 @@ def create_db(db_file): CREATE TABLE IF NOT EXISTS tokens ( token TEXT PRIMARY KEY, question_set TEXT, - answered BOOLEAN + answered BOOLEAN, + role TEXT ); """ ) db.commit() + def get_hash(s): return hashlib.sha224(s.encode('utf-8')).hexdigest() + def get_token_counts(db, key): cur = db.cursor() 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, ) ) used_tokens = cur.fetchall()[0][0] 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, ) @@ -60,6 +64,7 @@ def get_token_counts(db, key): } return tokens + def get_summary(db, key): """ returns summary for a vote event """ questions = [] @@ -92,7 +97,21 @@ def has_voted(key, token): return True cur = g.db.cursor() 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), key @@ -214,6 +233,7 @@ def parse_row_date(row): raise err return None + def write_vote(key, token, answers, form): cur = g.db.cursor() @@ -253,6 +273,7 @@ def write_vote(key, token, answers, form): ) g.db.commit() + def sort_summary(questions, answers): sorted_answer_list = [] for q,a in zip(questions, answers): @@ -264,6 +285,7 @@ def sort_summary(questions, answers): sorted_answer_list.append(sorted_answers) return questions, sorted_answer_list + def time_to_expiry(form): if form['expires'] == None: return "Never"