diff options
-rw-r--r-- | apioforum/db.py | 32 | ||||
-rw-r--r-- | apioforum/static/style.css | 6 | ||||
-rw-r--r-- | apioforum/templates/common.html | 7 | ||||
-rw-r--r-- | apioforum/templates/config_thread.html | 25 | ||||
-rw-r--r-- | apioforum/templates/view_thread.html | 56 | ||||
-rw-r--r-- | apioforum/thread.py | 165 |
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 = ?;", |