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 = ?;",  | 
