summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--apioforum/db.py32
-rw-r--r--apioforum/static/style.css6
-rw-r--r--apioforum/templates/common.html7
-rw-r--r--apioforum/templates/config_thread.html25
-rw-r--r--apioforum/templates/view_thread.html56
-rw-r--r--apioforum/thread.py165
6 files changed, 277 insertions, 14 deletions
diff --git a/apioforum/db.py b/apioforum/db.py
index e5159db..25bda94 100644
--- a/apioforum/db.py
+++ b/apioforum/db.py
@@ -84,6 +84,38 @@ CREATE TABLE thread_tags (
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN joined TIMESTAMP;
""",
+"""
+CREATE TABLE polls (
+ id INTEGER PRIMARY KEY,
+ title TEXT NOT NULL
+);
+ALTER TABLE threads ADD COLUMN poll INTEGER REFERENCES polls(id);
+
+CREATE TABLE poll_options (
+ poll INTEGER NOT NULL REFERENCES polls(id),
+ text TEXT NOT NULL,
+ option_idx INTEGER NOT NULL,
+ PRIMARY KEY ( poll, option_idx )
+);
+
+CREATE TABLE votes (
+ id INTEGER PRIMARY KEY,
+ user TEXT NOT NULL REFERENCES users(username),
+ poll INTEGER NOT NULL,
+ option_idx INTEGER,
+ time TIMESTAMP NOT NULL,
+ current INTEGER NOT NULL,
+ is_retraction INTEGER,
+ CHECK (is_retraction OR (option_idx NOT NULL)),
+ FOREIGN KEY ( poll, option_idx ) REFERENCES poll_options(poll, option_idx)
+);
+ALTER TABLE posts ADD COLUMN vote INTEGER REFERENCES votes(id);
+""",
+"""
+CREATE VIEW vote_counts AS
+ SELECT poll, option_idx, count(*) AS num FROM votes WHERE current GROUP BY option_idx,poll;
+""",
+
]
def init_db():
diff --git a/apioforum/static/style.css b/apioforum/static/style.css
index 3815860..6178dd9 100644
--- a/apioforum/static/style.css
+++ b/apioforum/static/style.css
@@ -16,8 +16,8 @@ body { font-family: sans-serif; word-wrap: break-word; }
}
.post:last-of-type { border-bottom: 1px solid black; }
-.post-heading { font-size: smaller; }
-.post-heading,a.username {
+.post-heading,.post-footer { font-size: smaller; }
+.post-heading,.post-footer,a.username {
color: hsl(0,0%,25%);
}
a.username {
@@ -32,6 +32,8 @@ a.username {
.post-heading-a { margin-left: 0.2em }
.post-heading-b { float: right; margin-right: 0.5em }
+.post-footer { margin-left: 0.2em; font-style: italic; }
+
.post-anchor-link { color: hsl(0,0%,25%); }
.thread-top-bar, .user-top-bar {
diff --git a/apioforum/templates/common.html b/apioforum/templates/common.html
index 8fcf5fe..3321085 100644
--- a/apioforum/templates/common.html
+++ b/apioforum/templates/common.html
@@ -6,7 +6,7 @@
{{url_for('thread.view_thread', thread_id=post.thread)}}#post_{{post.id}}
{%- endmacro %}
-{% macro disp_post(post, buttons=False) %}
+{% macro disp_post(post, buttons=False, footer=None) %}
<div class="post" id="post_{{post.id}}">
<div class="post-heading">
<span class="post-heading-a">
@@ -34,6 +34,11 @@
<div class="post-content md">
{{ post.content|md|safe }}
</div>
+ {% if footer %}
+ <div class="post-footer">
+ {{ footer }}
+ </div>
+ {% endif %}
</div>
{% endmacro %}
diff --git a/apioforum/templates/config_thread.html b/apioforum/templates/config_thread.html
index b26a73d..2c9804e 100644
--- a/apioforum/templates/config_thread.html
+++ b/apioforum/templates/config_thread.html
@@ -2,6 +2,7 @@
{% from 'common.html' import tag %}
{% block header %}<h1>{% block title %}configure thread '{{thread.title}}'{% endblock %}</h1>{% endblock %}
{% block content %}
+<h2>thread options</h2>
<form method="post">
<fieldset>
<legend>title</legend>
@@ -27,4 +28,28 @@
<input type="submit" value="confirm">
<a href="{{url_for('thread.view_thread',thread_id=thread.id)}}">cancel</a>
</form>
+
+{% if thread.poll is none %}
+<h2>create poll</h2>
+<form method="post" action="{{url_for('thread.create_poll',thread_id=thread.id)}}">
+ <fieldset>
+ <legend>create poll</legend>
+ <label for="polltitle">question title</label>
+ <input type="title" id="polltitle" name="polltitle">
+ <br>
+ <label for="polloptions">potential options (one per line)</label>
+ <textarea name="polloptions" id="polloptions"></textarea>
+ </fieldset>
+ <p>important: once a poll is created, you will not be able to modify it except to delete it entirely</p>
+ <input type="submit" value="create">
+</form>
+{% else %}
+<h2>delete poll</h2>
+<p>there is already a poll attached to this thread. you can delete it, which will allow you to create a new one, but this will erase all existing votes and data for the current poll.</p>
+<form action="{{url_for('thread.delete_poll',thread_id=thread.id)}}" method="post">
+ <input type="submit" value="confirm: delete poll">
+</form>
+
+{% endif %}
+
{% endblock %}
diff --git a/apioforum/templates/view_thread.html b/apioforum/templates/view_thread.html
index fb62880..7bf253d 100644
--- a/apioforum/templates/view_thread.html
+++ b/apioforum/templates/view_thread.html
@@ -5,6 +5,14 @@
{% endblock %}
{%block content%}
+{% if poll %}
+<p>{{poll.title}}</p>
+<ul>
+ {%for opt in poll.options%}
+ <li>#{{opt.option_idx}} - {{opt.text}} - {{opt.num or 0}}</li>
+ {%endfor%}
+</ul>
+{% endif %}
<div class="thread-top-bar">
<span class="thread-top-bar-a">
{% if g.user == thread.creator %}
@@ -21,12 +29,58 @@
<div class="posts">
{% for post in posts %}
- {{ disp_post(post, True) }}
+ {% if votes[post.id] %}
+
+ {% set vote = votes[post.id] %}
+ {% set option_idx = vote.option_idx %}
+
+ {# this is bad but it's going to get refactored anyway #}
+ {% set footer %}
+ {% if vote.is_retraction %}
+ {{post.author}} retracted their vote
+ {% else %}
+ {% set option = poll.options[option_idx-1] %}
+ {% if vote.current %}
+ {{post.author}} votes for {{option_idx}}: {{option.text}}
+ {% else %}
+ {{post.author}} voted for {{option_idx}}: {{option.text}}, but later changed their vote
+ {% endif %}
+ {% endif %}
+
+ {% endset %}
+
+ {{ disp_post(post, buttons=True, footer=footer) }}
+
+ {% else %}
+ {{ disp_post(post, buttons=True) }}
+ {% endif %}
{% endfor %}
</div>
{% if g.user %}
<form class="new-post" action="{{url_for('thread.create_post',thread_id=thread.id)}}" method="POST">
<textarea class="new-post-box" placeholder="your post here..." name="content"></textarea>
+ {% if poll %}
+ <fieldset>
+ <legend>poll: {{poll.title}}</legend>
+ <p>if you want, you can submit a vote along with this post. if you have previously voted
+ on this poll, your previous vote will be changed</p>
+
+ <input type="radio" id="dontvote" name="poll" value="dontvote" checked>
+ <label for="dontvote">do not submit any vote at the moment</label>
+
+ {% if has_voted %}
+ <br>
+ <input type="radio" id="retractvote" name="poll" value="retractvote">
+ <label for="retractvote">retract my vote, and go back to having no vote on this poll</label>
+ {% endif %}
+
+ {% for opt in poll.options %}
+ <br>
+ <input type="radio" id="option_{{opt.option_idx}}" name="poll" value="{{opt.option_idx}}">
+ <label for="option_{{opt.option_idx}}">submit a vote for #{{opt.option_idx}} - {{opt.text}}</label>
+ {% endfor %}
+ </fieldset>
+ {% endif %}
<input type="submit" value="yes">
</form>
{% else %}
diff --git a/apioforum/thread.py b/apioforum/thread.py
index 5f4b0af..daf0b85 100644
--- a/apioforum/thread.py
+++ b/apioforum/thread.py
@@ -1,8 +1,10 @@
# view posts in thread
+import itertools
+
from flask import (
Blueprint, render_template, abort, request, g, redirect,
- url_for, flash
+ url_for, flash, jsonify
)
from .db import get_db
@@ -18,17 +20,151 @@ def view_thread(thread_id):
if thread is None:
abort(404)
else:
- posts = db.execute(
- "SELECT * FROM posts WHERE thread = ? ORDER BY created ASC;",
- (thread_id,)
- ).fetchall()
+ posts = db.execute("""
+ SELECT * FROM posts
+ WHERE posts.thread = ?
+ ORDER BY created ASC;
+ """,(thread_id,)).fetchall()
tags = db.execute(
"""SELECT tags.* FROM tags
INNER JOIN thread_tags ON thread_tags.tag = tags.id
WHERE thread_tags.thread = ?
ORDER BY tags.id""",(thread_id,)).fetchall()
- return render_template("view_thread.html",posts=posts,thread=thread,tags=tags)
+ poll = None
+ votes = None
+ if thread['poll'] is not None:
+ poll_row = db.execute("SELECT * FROM polls where id = ?",(thread['poll'],)).fetchone()
+ options = db.execute("""
+ SELECT poll_options.*, vote_counts.num
+ FROM poll_options
+ LEFT OUTER JOIN vote_counts ON poll_options.poll = vote_counts.poll
+ AND poll_options.option_idx = vote_counts.option_idx
+ WHERE poll_options.poll = ?
+ ORDER BY option_idx asc;
+ """,(poll_row['id'],)).fetchall()
+ poll = {}
+ poll.update(poll_row)
+ poll['options'] = options
+ votes = {}
+ # todo: optimise this somehow
+ for post in posts:
+ if post['vote'] is not None:
+ votes[post['id']] = db.execute("SELECT * FROM votes WHERE id = ?",(post['vote'],)).fetchone()
+
+ if g.user is None or poll is None:
+ has_voted = None
+ else:
+ v = db.execute("SELECT * FROM votes WHERE poll = ? AND user = ? AND current AND NOT is_retraction;",(poll['id'],g.user)).fetchone()
+ has_voted = v is not None
+
+ return render_template(
+ "view_thread.html",
+ posts=posts,
+ thread=thread,
+ tags=tags,
+ poll=poll,
+ votes=votes,
+ has_voted=has_voted,
+ )
+
+def register_vote(thread,pollval):
+ if pollval is None or pollval == 'dontvote':
+ return
+
+ is_retraction = pollval == 'retractvote'
+
+ if is_retraction:
+ option_idx = None
+ else:
+ option_idx = int(pollval)
+
+ db = get_db()
+ cur = db.cursor()
+ cur.execute("""
+ UPDATE votes
+ SET current = 0
+ WHERE poll = ? AND user = ?;
+ """,(thread['poll'],g.user))
+
+ cur.execute("""
+ INSERT INTO votes (user,poll,option_idx,time,current,is_retraction)
+ VALUES (?,?,?,current_timestamp,1,?);
+ """,(g.user,thread['poll'],option_idx,is_retraction))
+ vote_id = cur.lastrowid
+ return vote_id
+
+@bp.route("/<int:thread_id>/create_poll",methods=["POST"])
+def create_poll(thread_id):
+ fail = redirect(url_for('thread.config_thread',thread_id=thread_id))
+ success = redirect(url_for('thread.view_thread',thread_id=thread_id))
+ err = None
+ db = get_db()
+ thread = db.execute('select * from threads where id = ?',(thread_id,)).fetchone()
+
+ polltitle = request.form.get('polltitle','').strip()
+ polloptions = [q.strip() for q in request.form.get('polloptions','').split("\n") if len(q.strip()) > 0]
+
+ if thread is None:
+ err = "that thread does not exist"
+ elif g.user is None:
+ err = "you need to be logged in to do that"
+ elif g.user != thread['creator']:
+ err = "you can only create polls on threads that you own"
+ elif thread['poll'] is not None:
+ err = "a poll already exists for that thread"
+ elif not len(polltitle) > 0:
+ err = "poll title can't be empty"
+ elif len(polloptions) < 2:
+ err = "you must provide at least 2 options"
+
+ if err is not None:
+ flash(err)
+ return fail
+ else:
+ cur = db.cursor()
+ cur.execute("INSERT INTO polls (title) VALUES (?)",(polltitle,))
+ pollid = cur.lastrowid
+ cur.execute("UPDATE threads SET poll = ? WHERE threads.id = ?",(pollid,thread_id))
+ cur.executemany(
+ "INSERT INTO poll_options (poll,option_idx,text) VALUES (?,?,?)",
+ zip(itertools.repeat(pollid),itertools.count(1),polloptions)
+ )
+ db.commit()
+ flash("poll created successfully")
+ return success
+
+@bp.route("/<int:thread_id>/delete_poll",methods=["POST"])
+def delete_poll(thread_id):
+ fail = redirect(url_for('thread.config_thread',thread_id=thread_id))
+ success = redirect(url_for('thread.view_thread',thread_id=thread_id))
+ err = None
+ db = get_db()
+ thread = db.execute('select * from threads where id = ?',(thread_id,)).fetchone()
+
+ if thread is None:
+ err = "that thread does not exist"
+ elif g.user is None:
+ err = "you need to be logged in to do that"
+ elif g.user != thread['creator']:
+ err = "you can only delete polls on threads that you own"
+ elif thread['poll'] is None:
+ err = "there is no poll to delete on this thread"
+
+ if err is not None:
+ flash(err)
+ return fail
+ else:
+ pollid = thread['poll']
+ db.execute("UPDATE posts SET vote = NULL WHERE thread = ?",(thread_id,)) # this assumes only max one poll per thread
+ db.execute("DELETE FROM votes WHERE poll = ?",(pollid,))
+ db.execute("DELETE FROM poll_options WHERE poll = ?",(pollid,))
+ db.execute("UPDATE THREADS set poll = NULL WHERE id = ?",(thread_id,))
+ db.execute("DELETE FROM polls WHERE id = ?",(pollid,))
+ db.commit()
+ flash("poll deleted successfully")
+ return success
+
@bp.route("/<int:thread_id>/create_post", methods=("POST",))
def create_post(thread_id):
if g.user is None:
@@ -43,11 +179,20 @@ def create_post(thread_id):
elif not thread:
flash("that thread does not exist")
else:
+ vote_id = None
+ if thread['poll'] is not None:
+ pollval = request.form.get('poll')
+ try:
+ vote_id = register_vote(thread,pollval)
+ except ValueError:
+ flash("invalid poll form value")
+ return redirect(url_for('thread.view_thread',thread_id=thread_id))
+
cur = db.cursor()
- cur.execute(
- "INSERT INTO posts (thread,author,content,created) VALUES (?,?,?,current_timestamp);",
- (thread_id,g.user,content)
- )
+ cur.execute("""
+ INSERT INTO posts (thread,author,content,created,vote)
+ VALUES (?,?,?,current_timestamp,?);
+ """,(thread_id,g.user,content,vote_id))
post_id = cur.lastrowid
cur.execute(
"UPDATE threads SET updated = current_timestamp WHERE id = ?;",