initial working state
This commit is contained in:
29
README.md
Normal file
29
README.md
Normal file
@@ -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`
|
||||||
|
|
||||||
|
|
||||||
93
abot.py
Normal file
93
abot.py
Normal file
@@ -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/<key>')
|
||||||
|
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/<key>/<token>')
|
||||||
|
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)
|
||||||
7
install.me
Executable file
7
install.me
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
[[ -d virtualenv/bin ]] || virtualenv -p python3 virtualenv
|
||||||
|
|
||||||
|
. virtualenv/bin/activate
|
||||||
|
|
||||||
|
pip3 install -r requirements.txt --upgrade
|
||||||
3
mailing/emails.txt
Normal file
3
mailing/emails.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
an.address@domain.net
|
||||||
|
other.address@domain.net
|
||||||
|
|
||||||
17
mailing/send_tokens.sh
Executable file
17
mailing/send_tokens.sh
Executable file
@@ -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
|
||||||
9
mailing/template.txt
Normal file
9
mailing/template.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Dear Recipient,
|
||||||
|
|
||||||
|
You have been sent an invitation to vote at an anonymous voting event:
|
||||||
|
|
||||||
|
{{ url }}
|
||||||
|
|
||||||
|
Thank you for voting!
|
||||||
|
|
||||||
|
|
||||||
4
manager
Executable file
4
manager
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd $( dirname "$0" )
|
||||||
|
. virtualenv/bin/activate
|
||||||
|
python3 ./abot.py "$@"
|
||||||
301
manager.py
Normal file
301
manager.py
Normal file
@@ -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)
|
||||||
|
|
||||||
22
questions/multi_question.txt
Normal file
22
questions/multi_question.txt
Normal file
@@ -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: _____
|
||||||
|
|
||||||
|
|
||||||
9
questions/simple_example.txt
Normal file
9
questions/simple_example.txt
Normal file
@@ -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
|
||||||
|
|
||||||
|
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask
|
||||||
|
gunicorn
|
||||||
32
revprox.py
Normal file
32
revprox.py
Normal file
@@ -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)
|
||||||
4
start.me
Executable file
4
start.me
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd $( dirname "$0" )
|
||||||
|
. virtualenv/bin/activate
|
||||||
|
exec gunicorn -b 127.0.0.1:8041 -w 3 abot:app
|
||||||
0
static/script.js
Normal file
0
static/script.js
Normal file
34
static/style.css
Normal file
34
static/style.css
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
4
templates/blank.html
Normal file
4
templates/blank.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
{{ message }}
|
||||||
|
{% endblock %}
|
||||||
8
templates/index.html
Normal file
8
templates/index.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>aBot!</h1>
|
||||||
|
|
||||||
|
<div class=index>Anonymous voting engine</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
12
templates/layout.html
Normal file
12
templates/layout.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<head>
|
||||||
|
<title>aBot</title>
|
||||||
|
<meta name="viewport" content="width=440" />
|
||||||
|
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<script src="{{ url_for('static', filename='script.js') }}" type="text/javascript"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class=page>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
9
templates/preview.html
Normal file
9
templates/preview.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<div id="preview_header">
|
||||||
|
Preview for: {{ key|safe }}<br>
|
||||||
|
Expires: {{ valid_for }}<br>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "questions.html" %}
|
||||||
|
{% endblock %}
|
||||||
25
templates/questions.html
Normal file
25
templates/questions.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<div id="questions">
|
||||||
|
{% for question in form.questions %}
|
||||||
|
<div class = "question">
|
||||||
|
<h2>{{ question.name|safe }}</h2>
|
||||||
|
|
||||||
|
{% for choice in question.choices %}
|
||||||
|
<div>
|
||||||
|
<input type="radio" name="QC{{ question.index }}" value="{{ choice|safe }}" />
|
||||||
|
<label>{{ choice|safe }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for choice in question.multichoices %}
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" name="QM{{ question.index }}" value="{{ choice|safe }}" />
|
||||||
|
<label>{{ choice|safe }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if question.open_question %}
|
||||||
|
<div>
|
||||||
|
<textarea name="QO{{ question.index }}" rows="5"></textarea>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
8
templates/thank_you.html
Normal file
8
templates/thank_you.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>aBot!</h1>
|
||||||
|
|
||||||
|
Thank you for the vote!
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
14
templates/vote.html
Normal file
14
templates/vote.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<div id="vote_header">Voting ends at: {{ valid_for }}</div>
|
||||||
|
<form id="vote_form" action="{{ url_for('save_vote') }}" method=post >
|
||||||
|
<input type=hidden value="{{ key|safe }}" name=key />
|
||||||
|
<input type=hidden value="{{ token|safe }}" name=token />
|
||||||
|
|
||||||
|
{% include "questions.html" %}
|
||||||
|
<p>
|
||||||
|
<input type=submit name=submit /><br>
|
||||||
|
<span class = "warning">Votes can not be edited later!</span>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
191
utils.py
Normal file
191
utils.py
Normal file
@@ -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 )
|
||||||
Reference in New Issue
Block a user