From 083915e69a5fd1b2f9785d018ef055dd195a4c6a Mon Sep 17 00:00:00 2001 From: Ville Rantanen Date: Sun, 2 Dec 2018 23:11:32 +0200 Subject: [PATCH] initial working state --- README.md | 29 ++++ abot.py | 93 +++++++++++ install.me | 7 + mailing/emails.txt | 3 + mailing/send_tokens.sh | 17 ++ mailing/template.txt | 9 ++ manager | 4 + manager.py | 301 +++++++++++++++++++++++++++++++++++ questions/multi_question.txt | 22 +++ questions/simple_example.txt | 9 ++ requirements.txt | 2 + revprox.py | 32 ++++ start.me | 4 + static/script.js | 0 static/style.css | 34 ++++ templates/blank.html | 4 + templates/index.html | 8 + templates/layout.html | 12 ++ templates/preview.html | 9 ++ templates/questions.html | 25 +++ templates/thank_you.html | 8 + templates/vote.html | 14 ++ utils.py | 191 ++++++++++++++++++++++ 23 files changed, 837 insertions(+) create mode 100644 README.md create mode 100644 abot.py create mode 100755 install.me create mode 100644 mailing/emails.txt create mode 100755 mailing/send_tokens.sh create mode 100644 mailing/template.txt create mode 100755 manager create mode 100644 manager.py create mode 100644 questions/multi_question.txt create mode 100644 questions/simple_example.txt create mode 100644 requirements.txt create mode 100644 revprox.py create mode 100755 start.me create mode 100644 static/script.js create mode 100644 static/style.css create mode 100644 templates/blank.html create mode 100644 templates/index.html create mode 100644 templates/layout.html create mode 100644 templates/preview.html create mode 100644 templates/questions.html create mode 100644 templates/thank_you.html create mode 100644 templates/vote.html create mode 100644 utils.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc409b3 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# aBot engine + +- Make voting questions in simple text files +- single choice, multiple choice and open text questions supported + - read more from example questions sets +- Preview form online +- Get tokens for voting in command line +- Send unique URLs to your voters (mailing example script included) +- Summarize results in the command line + +- Online part runs with python3 + flask + gunicorn +- Command line tools python3 + + + +## Usage instructions + +- Install requirements `./install.me` +- Start server `./start.me` +- Create questions in `questions/my_poll.txt` +- Have `draft: true` in the questions, and view the poll at + [preview](http://localhost:8041/preview/my_poll) +- Change to `draft: false` to enable voting +- Add tokens for the vote: + `./manager token --prefix http://localhost:8041 my_poll` +- Open the link to vote +- Get summary of votes: `./manager summary my_poll` + + diff --git a/abot.py b/abot.py new file mode 100644 index 0000000..08c2b98 --- /dev/null +++ b/abot.py @@ -0,0 +1,93 @@ + +import sqlite3 +from flask import Flask, request, g, url_for, \ + render_template +from revprox import ReverseProxied +from utils import * +import manager + +DATABASE = 'abot.sqlite' +DEBUG = False +SECRET_KEY = 'otwet6oi539iosf' +QUESTIONS = 'questions' # path to questions + +# create our little application :) +app = Flask(__name__) +app.config.from_object(__name__) +app.wsgi_app = ReverseProxied(app.wsgi_app) + +def connect_db(): + return sqlite3.connect(app.config['DATABASE']) + +@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): + return render_template('blank.html', message = "Unknown key") + form = parse_form(key) + if not form: + return render_template('blank.html', message = "Error creating form") + if not is_draft(form): + return render_template('blank.html', message = "Preview not enabled") + valid_for = time_to_expiry(form) + return render_template( + 'preview.html', + key = key, + form = form, + valid_for = valid_for + ) + +@app.route('/vote//') +def vote(key, token): + if not is_key(key): + return render_template('blank.html', message = "Unknown key") + form = parse_form(key) + if not form: + return render_template('blank.html', message = "Error creating form") + if is_draft(form): + return render_template('blank.html', message = "Not published") + if is_expired(form): + return render_template('blank.html', message = "Voting has closed") + if has_voted(key, token): + return render_template('blank.html', message = "Token already used") + valid_for = time_to_expiry(form) + + 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'] + token = request.form['token'] + if not is_key(key): + return render_template('blank.html', message = "Unknown key") + form = parse_form(key) + if not form: + return render_template('blank.html', message = "Error creating form") + create_voter_table(g.db, key) + if is_draft(form): + return render_template('blank.html', message = "Not published") + if is_expired(form): + return render_template('blank.html', message = "Voting has closed") + if has_voted(key, token): + return render_template('blank.html', message = "Token already used") + create_result_table(key) + + write_vote(key, token, request.form, form) # using request. + + return render_template('thank_you.html') + +if __name__ == "__main__": + manager.main(DATABASE, QUESTIONS) diff --git a/install.me b/install.me new file mode 100755 index 0000000..f0d5021 --- /dev/null +++ b/install.me @@ -0,0 +1,7 @@ +#!/bin/bash + +[[ -d virtualenv/bin ]] || virtualenv -p python3 virtualenv + +. virtualenv/bin/activate + +pip3 install -r requirements.txt --upgrade diff --git a/mailing/emails.txt b/mailing/emails.txt new file mode 100644 index 0000000..8560c3d --- /dev/null +++ b/mailing/emails.txt @@ -0,0 +1,3 @@ +an.address@domain.net +other.address@domain.net + diff --git a/mailing/send_tokens.sh b/mailing/send_tokens.sh new file mode 100755 index 0000000..622ded4 --- /dev/null +++ b/mailing/send_tokens.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +[[ -z "$2" ]] && { + echo "$0 email_list.txt template.txt" + exit +} +set -x +prefix=http://localhost:8041 +reply=no.reply@localhost +addresses=$( grep @ "$1" | wc -l ) +i=1 +../manager token multi_question -n $addresses --prefix $prefix | while read url; do + address=$( grep @ "$1" | sed "${i}q;d" ) + cat "$2" | sed "s,{{ url }},$url," | mail -r $reply -s "Invitation to vote" "$address" + sleep 3 + i=$(( i + 1 )) +done diff --git a/mailing/template.txt b/mailing/template.txt new file mode 100644 index 0000000..745777b --- /dev/null +++ b/mailing/template.txt @@ -0,0 +1,9 @@ +Dear Recipient, + +You have been sent an invitation to vote at an anonymous voting event: + +{{ url }} + +Thank you for voting! + + diff --git a/manager b/manager new file mode 100755 index 0000000..b2ad0f9 --- /dev/null +++ b/manager @@ -0,0 +1,4 @@ +#!/bin/bash +cd $( dirname "$0" ) +. virtualenv/bin/activate +python3 ./abot.py "$@" diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..9345787 --- /dev/null +++ b/manager.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +from datetime import datetime +from utils import * +import argparse +import os +import random +import sqlite3 +import string +import sys + + +def insert_token(db, name, token): + table_name = get_voter_table_name(name) + cur = db.cursor() + + cur.execute(""" + INSERT INTO `%s` VALUES ( + ?, + 'false' + ); + """%( table_name, ), + ( + token, + ) + ) + db.commit() + + +def add_token(options): + if not is_key(options.name, options): + raise Exception("%s does not exist, or is not a valid question set name"%( options.name, )) + db = open_db(options.db) + create_voter_table(db, options.name) + 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"%( + options.prefix, + options.name, + token + )) + +def open_db(db): + return sqlite3.connect(db) + + +def parse_options(database, questions): + path_self = os.path.realpath( + os.path.dirname( + os.path.realpath(__file__) + ) + ) + default_db = os.path.join(path_self, database) + default_questions = os.path.join(path_self, questions) + + parser = argparse.ArgumentParser(description='aBot vote manager') + parser.add_argument('--db', action="store", dest="db", default = default_db, + help = "Path to database [%(default)s]") + parser.add_argument('--questions', action="store", dest="questions", default = default_questions, + help = "Path to question folder [%(default)s]") + + + subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name') + ## tokens + parser_token = subparsers.add_parser('token', help = "Manage tokens") + parser_token.add_argument( + '-n', + action="store", + dest="number", + default = 1, type = int, + help = "Number of tokens to generate" + ) + parser_token.add_argument( + '--prefix', + action="store", + dest="prefix", + default = "", + help = "Prefix tokens with the server URL to automate emails etc.." + ) + parser_token.add_argument( + dest = "name", + help = "Name of the question set" + ) + + ## summary of vote + parser_summary = subparsers.add_parser('summary', help = "Vote results") + parser_summary.add_argument( + '--tsv', + action="store_true", + dest="tsv", + default = False, + help = "TSV output" + ) + parser_summary.add_argument( + dest = "name", + help = "Name of the question set" + ) + ## clear + parser_clear = subparsers.add_parser('clear', help = "Delete results") + parser_clear.add_argument( + '--really', + action="store_true", + dest="really", + default = False, + help = "Really delete results for the vote" + ) + parser_clear.add_argument( + '--tokens', + action="store_true", + dest="tokens", + default = False, + help = "Delete tokens too" + ) + parser_clear.add_argument( + dest = "name", + help = "Name of the question set" + ) + + ## list + parser_list = subparsers.add_parser('list', help = "List all question set names") + + parsed = parser.parse_args() + if parsed.subparser_name == None: + parser.print_help() + return parsed + + +def list_question_sets(options): + for f in os.listdir(options.questions): + if not f.endswith(".txt"): + continue + print(f[0:-4]) + + +def summary(options): + if not is_key(options.name, options): + raise Exception("%s does not exist, or is not a valid question set name"%( options.name, )) + db = open_db(options.db) + cur = db.cursor() + + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?;", + ( options.name, ) + ) + matching_tables = cur.fetchall() + if len(matching_tables) == 0: + print("No votes yet") + return + token_table = get_voter_table_name(options.name) + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?;", + ( token_table, ) + ) + matching_tables = cur.fetchall() + if len(matching_tables) == 0: + used_tokens = 0 + unused_tokens = 0 + else: + cur.execute( + "SELECT count(*) FROM `%s` WHERE answered = 'true'"%( + token_table, + ) + ) + used_tokens = cur.fetchall()[0][0] + cur.execute( + "SELECT count(*) FROM `%s` WHERE answered = 'false'"%( + token_table, + ) + ) + unused_tokens = cur.fetchall()[0][0] + tokens = { + 'unused': unused_tokens, + 'used': used_tokens, + 'total': used_tokens + unused_tokens + } + + questions = [] + answers = {} + cur.execute( + "SELECT question, answer, answer_type FROM `%s`"%( + options.name, + ) + ) + for row in cur: + if row[0] not in answers.keys(): + questions.append(row[0]) + answers[row[0]] = { + 'answers': {}, + 'answer_type': row[2] + } + + if row[1] not in answers[row[0]]['answers'].keys(): + answers[row[0]]['answers'][row[1]] = 0 + answers[row[0]]['answers'][row[1]] += 1 + + try: + if options.tsv: + summary_tsv(questions, answers, tokens) + else: + summary_list(questions, answers, tokens) + except AttributeError: + summary_list(questions, answers, tokens) + +def summary_list(questions, answers, tokens): + print( + """# Tokens for this question set: +# used: {used}, unused: {unused}, total: {total}""".format_map(tokens) + ) + + for q,a in zip(questions, answers): + sum_answers = sum([answers[q]['answers'][x] for x in answers[q]['answers']]) + print("\n%s\n# Answers total: %d"%( q, sum_answers, )) + sorted_answers = sorted( + [(x, answers[q]['answers'][x]) for x in answers[q]['answers']], + key = lambda i: -i[1] + ) + for answer in sorted_answers: + prefix = "" + postfix = "" + if answers[q]['answer_type'] == "single": + prefix = "- " + postfix = ": %d (%d%%) "%( answer[1], 100 * float(answer[1])/sum_answers) + if answers[q]['answer_type'] == "multiple": + prefix = "+ " + postfix = ": %d (%d%%) "%( answer[1], 100 * float(answer[1])/sum_answers) + if answers[q]['answer_type'] == "open": + prefix = "----\n> " + postfix = "" + + print("%s%s%s"%( + prefix, + answer[0], + postfix + )) + + +def summary_tsv(questions, answers, tokens): + print( + '''Tokens\tUsed\tUnused\tTotal +""\t"{used}"\t"{unused}"\t"{total}"'''.format_map(tokens) + ) + print('"Question"\t"Question type"\t"Answer"\t"Count"') + good_characters = dict.fromkeys(range(32)) + for q,a in zip(questions, answers): + sum_answers = sum([answers[q]['answers'][x] for x in answers[q]['answers']]) + print('"%s"\t"%s"\t""\t"%d"'%( q, answers[q]['answer_type'], sum_answers, )) + sorted_answers = sorted( + [(x, answers[q]['answers'][x]) for x in answers[q]['answers']], + key = lambda i: -i[1] + ) + for answer in sorted_answers: + + print('""\t""\t""%s\t"%d"'%( + answer[0].translate(good_characters), + answer[1], + )) + + +def clear_votes(options): + if not is_key(options.name, options): + raise Exception("%s does not exist, or is not a valid question set name"%( options.name, )) + summary(options) + if not options.really: + print("\nNot really deleting results") + sys.exit(0) + db = open_db(options.db) + cur = db.cursor() + + cur.execute( + "DROP TABLE IF EXISTS `%s`"%( options.name, ) + ) + db.commit() + print("\nDeleted votes for %s"%( options.name, )) + + +def clear_tokens(options): + if not is_key(options.name, options): + raise Exception("%s does not exist, or is not a valid question set name"%( options.name, )) + db = open_db(options.db) + cur = db.cursor() + cur.execute( + "DROP TABLE IF EXISTS `%s`"%( get_voter_table_name(options.name,) ) + ) + db.commit() + print("\nDeleted tokens for %s"%( options.name, )) + +def main(database, questions): + options = parse_options(database, questions) + if options.subparser_name == "list": + list_question_sets(options) + if options.subparser_name == "token": + add_token(options) + if options.subparser_name == "summary": + summary(options) + if options.subparser_name == "clear": + clear_votes(options) + if options.tokens: + clear_tokens(options) + diff --git a/questions/multi_question.txt b/questions/multi_question.txt new file mode 100644 index 0000000..d2f1481 --- /dev/null +++ b/questions/multi_question.txt @@ -0,0 +1,22 @@ +# 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 +# if "draft: true" voting is not possible. you can preview the form with address /preview/example +draft: false + +# - character is single choice question +It works? +- yes +- no + +# + character is multi choice question +Fruits ++ banana ++ orange ++ tomato + +# Question ending with ___ is an open text question +Explain: _____ + + diff --git a/questions/simple_example.txt b/questions/simple_example.txt new file mode 100644 index 0000000..0ea3dcd --- /dev/null +++ b/questions/simple_example.txt @@ -0,0 +1,9 @@ + +# if "true" voting is not possible. you can preview the form in address /preview/example +draft: false + +It works? +- yes +- no + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e4a286c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +gunicorn diff --git a/revprox.py b/revprox.py new file mode 100644 index 0000000..9649e61 --- /dev/null +++ b/revprox.py @@ -0,0 +1,32 @@ +class ReverseProxied(object): + '''Wrap the application in this middleware and configure the + front-end server to add these headers, to let you quietly bind + this to a URL other than / and to an HTTP scheme that is + different than what is used locally. + + In nginx: + location /myprefix { + proxy_pass http://192.168.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Script-Name /myprefix; + } + + :param app: the WSGI application + ''' + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_SCRIPT_NAME', '') + if script_name: + environ['SCRIPT_NAME'] = script_name + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO'] = path_info[len(script_name):] + + scheme = environ.get('HTTP_X_SCHEME', '') + if scheme: + environ['wsgi.url_scheme'] = scheme + return self.app(environ, start_response) diff --git a/start.me b/start.me new file mode 100755 index 0000000..2b8fdf8 --- /dev/null +++ b/start.me @@ -0,0 +1,4 @@ +#!/bin/bash +cd $( dirname "$0" ) +. virtualenv/bin/activate +exec gunicorn -b 127.0.0.1:8041 -w 3 abot:app diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..e69de29 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..061b9ff --- /dev/null +++ b/static/style.css @@ -0,0 +1,34 @@ +body { font-family: sans-serif; background: #eee; } +a, h1, h2 { color: #377ba8; } +h1, h2 { font-family: 'Georgia', serif; margin: 0; } +h1 { border-bottom: 2px solid #eee; } +h2 { font-size: 1.2em; } +input { margin-top: 0.5em; border: 1px solid gray;} + +.page { margin: 2em auto; width: 90%; border: 5px solid #ccc; + padding: 0.8em; background: white; } +.entries { list-style: none; margin: 0; padding: 0; } +.entries li { margin: 0.8em 1.2em; } +.entries li h2 { margin-left: -1em; } +table.entriesall { border-collapse: collapse; } +.entriesall td, .entriesall th { border: 1px solid black; + padding: 0.5em; } +.index { + margin-top: 1em; +} + +#questions { + margin-top: 1em; +} +.question { + border-bottom: 1px solid gray; + padding-bottom: 0.5em; +} +.warning { + font-size: small; + color: red; +} + +textarea { + width: 90%; +} diff --git a/templates/blank.html b/templates/blank.html new file mode 100644 index 0000000..abf7879 --- /dev/null +++ b/templates/blank.html @@ -0,0 +1,4 @@ +{% extends "layout.html" %} +{% block body %} + {{ message }} +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..75e5b26 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,8 @@ +{% extends "layout.html" %} +{% block body %} +

aBot!

+ +
Anonymous voting engine
+ + +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..50fb244 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,12 @@ + + +aBot + + + + + +
+ {% block body %}{% endblock %} +
+ diff --git a/templates/preview.html b/templates/preview.html new file mode 100644 index 0000000..4858fed --- /dev/null +++ b/templates/preview.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% block body %} +
+ Preview for: {{ key|safe }}
+ Expires: {{ valid_for }}
+
+ + {% include "questions.html" %} +{% endblock %} diff --git a/templates/questions.html b/templates/questions.html new file mode 100644 index 0000000..8fad537 --- /dev/null +++ b/templates/questions.html @@ -0,0 +1,25 @@ +
+ {% for question in form.questions %} +
+

{{ question.name|safe }}

+ + {% for choice in question.choices %} +
+ + +
+ {% endfor %} + {% for choice in question.multichoices %} +
+ + +
+ {% endfor %} + {% if question.open_question %} +
+ +
+ {% endif %} +
+ {% endfor %} +
diff --git a/templates/thank_you.html b/templates/thank_you.html new file mode 100644 index 0000000..7c6ccac --- /dev/null +++ b/templates/thank_you.html @@ -0,0 +1,8 @@ +{% extends "layout.html" %} +{% block body %} +

aBot!

+ +Thank you for the vote! + + +{% endblock %} diff --git a/templates/vote.html b/templates/vote.html new file mode 100644 index 0000000..8d8f73a --- /dev/null +++ b/templates/vote.html @@ -0,0 +1,14 @@ +{% extends "layout.html" %} +{% block body %} +
Voting ends at: {{ valid_for }}
+
+ + + + {% include "questions.html" %} +

+
+ Votes can not be edited later! +

+
+{% endblock %} diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..2b40da6 --- /dev/null +++ b/utils.py @@ -0,0 +1,191 @@ +from datetime import datetime, timezone +from flask import current_app as app +from flask import g +import os +from werkzeug.utils import secure_filename + +def create_result_table(key): + cur = g.db.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS `%s` ( + question TEXT, + answer TEXT, + answer_type TEXT + ); + """%(key, ) + ) + g.db.commit() + +def create_voter_table(db, name): + table_name = get_voter_table_name(name) + cur = db.cursor() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS `%s` ( + token TEXT PRIMARY KEY, + answered BOOLEAN + ); + """%(table_name, ) + ) + db.commit() + +def get_voter_table_name(key): + return key + "__voters" + + +def get_result_table_name(key): + return key + + +def has_voted(key, token): + cur = g.db.cursor() + cur.execute( + "SELECT token FROM %s WHERE token = ? AND answered = 'true'"%( + get_voter_table_name(key), + ), + ( + token, + ) + ) + return len(cur.fetchall()) > 0 + + +def is_draft(form): + return form['draft'] + +def is_expired(form): + if form['expires'] == None: + return False + + return datetime.now(timezone.utc) > form['expires'] + +def is_key(key, cli_opts = False): + key = secure_filename(key) + + if cli_opts: + root_path = cli_opts.questions + else: + root_path = app.config['QUESTIONS'] + + return os.path.exists( + os.path.join( + root_path, + key + ".txt" + ) + ) + +def parse_form(key): + form = { + 'expires': None, + 'draft': False, + 'questions': [] + } + key = secure_filename(key) + try: + current_question = None + with open(os.path.join(app.config['QUESTIONS'], key + ".txt"), "rt") as fp: + for row in fp: + if row.strip() == "": + continue + if row.startswith("#"): + continue + if row.lower().startswith("expires: "): + form['expires'] = parse_row_date(row) + continue + if row.lower().startswith("draft: "): + if row.lower().rstrip() == "draft: true": + form['draft'] = True + continue + if row.startswith("- "): + if current_question == None: + continue + form['questions'][current_question]['choices'].append( + row[2:].strip() + ) + continue + if row.startswith("+ "): + if current_question == None: + continue + form['questions'][current_question]['multichoices'].append( + row[2:].strip() + ) + continue + current_question = len(form['questions']) + form['questions'].append({ + 'choices': [], + 'multichoices': [], + 'index': len(form['questions']) + 1, + 'name': row.strip().rstrip("_:").rstrip(), + 'open_question': row.strip().endswith("___") + }) + return form + except Exception as err: + if app.config['DEBUG']: + raise err + return False + + +def parse_row_date(row): + row = row[9:].strip() + if row.lower() == "none": + return None + try: + return datetime.strptime( + row, + '%Y-%m-%d %H:%M %z' + ) + except Exception as err: + if app.config['DEBUG']: + print(row) + raise err + return None + +def write_vote(key, token, answers, form): + + cur = g.db.cursor() + for question in form['questions']: + answer = None + answer_type = None + if 'QC%d'%( question['index'], ) in answers: + answer = (answers['QC%d'%( question['index'], )],) + answer_type = "single" + if 'QM%d'%( question['index'], ) in answers: + answer = answers.getlist('QM%d'%( question['index'], )) + answer_type = "multiple" + if 'QO%d'%( question['index'], ) in answers: + answer = (answers['QO%d'%( question['index'], )],) + answer_type = "open" + if answer == None: + continue + for single in answer: + cur.execute( + "INSERT INTO %s VALUES (?, ?, ?)"%( + key, + ), + ( + question['name'], + single.strip(), + answer_type + ) + ) + + cur.execute( + "UPDATE %s SET answered = 'true' WHERE token = ?"%( + get_voter_table_name(key), + ), + ( + token, + ) + ) + g.db.commit() + + +def time_to_expiry(form): + if form['expires'] == None: + return "Never" + + time_to_go = form['expires'] - datetime.now(timezone.utc) + time_to_go = ".".join(str(time_to_go).split('.')[0:-1])[0:-3] + + #time_to_go.microseconds = 0 + return "%s (%s to go)"%( form['expires'], time_to_go )