From 8d8977353975a978cf7aa47d6312e93472276563 Mon Sep 17 00:00:00 2001 From: Lukas Winkler Date: Mon, 26 Mar 2018 21:56:25 +0200 Subject: [PATCH] many improvements (votes, filter, header, etc.) --- models.py | 1 + server.py | 105 ++++++++++++++++--------- templates/base.html | 2 + templates/detail.html | 41 +++++----- templates/list.html | 16 +++- templates/macros.html | 9 +++ text_generator.py | 2 +- todb.py | 1 - utils.py | 19 ++++- web/static/js/app.js | 47 ++++++++++- web/static/js/awesomplete.min.js | 3 + web/static/sass/_awesomplete.base.scss | 33 ++++++++ web/static/sass/style.scss | 24 +++--- 13 files changed, 226 insertions(+), 77 deletions(-) create mode 100644 web/static/js/awesomplete.min.js create mode 100644 web/static/sass/_awesomplete.base.scss diff --git a/models.py b/models.py index 1bccf86..1128f77 100644 --- a/models.py +++ b/models.py @@ -46,6 +46,7 @@ class Answer(BaseModel): text = TextField() upvotes = IntegerField(default=0) downvotes = IntegerField(default=0) + datetime = DateTimeField() question = ForeignKeyField(Question, null=True) user = ForeignKeyField(User) site = ForeignKeyField(Site) diff --git a/server.py b/server.py index 66557be..6256aea 100644 --- a/server.py +++ b/server.py @@ -1,3 +1,5 @@ +from datetime import datetime + import sass from flask import render_template, send_from_directory, abort, session, jsonify, make_response from flask_limiter import Limiter @@ -20,23 +22,22 @@ limiter = Limiter( key_func=get_remote_address, headers_enabled=True ) -import logging - -logger = logging.getLogger('peewee') -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) @app.route('/') -def index(): - select = """ - *, - ((upvotes + 1.9208) / (upvotes + downvotes) - - 1.96 * SQRT((upvotes * downvotes) / (upvotes + downvotes) + 0.9604) / - (upvotes + downvotes)) / (1 + 3.8416 / (upvotes + downvotes)) - AS ci_lower_bound - """ - query = Question.select(SQL(select)).order_by(SQL("ci_lower_bound DESC, random")) +@app.route('/s/') +def index(site=None): + query = Question.select(Question, User, Site, Title, SQL(utils.rating_sql)).join(Site).switch(Question).join( + User).switch( + Question).join( + Title) + if site: + query = query.where(Site.url == site) + site_element = Site.select().where(Site.url == site).get() + else: + site_element = utils.get_fallback_site() + query = query.order_by(SQL("ci_lower_bound DESC, random")) + # return jsonify(model_to_dict(query.get())) paginated_query = PaginatedQuery(query, paginate_by=10, check_bounds=True) pagearray = utils.create_pagination(paginated_query.get_page_count(), paginated_query.get_page()) return render_template( @@ -44,52 +45,81 @@ def index(): pagearray=pagearray, num_pages=paginated_query.get_page_count(), page=paginated_query.get_page(), - questions=paginated_query.get_object_list() + questions=paginated_query.get_object_list(), + site=site_element ) @app.route('/q/') def question(slug): - query = Question.select().join(Title).where(Title.slug == slug) + query = Question.select(Question, Title, User, Site) \ + .join(Title).switch(Question) \ + .join(User).switch(Question) \ + .join(Site).where(Title.slug == slug) question = get_object_or_404(query) - answers = Answer.select().where(Answer.question == question) # TODO: Sort by score + answers = Answer.select(Answer, User, SQL(utils.rating_sql)) \ + .join(User).where(Answer.question == question) \ + .order_by(SQL("ci_lower_bound DESC")) return render_template( "detail.html", - debug=model_to_dict(question), question=question, answers=answers ) +@app.route('/api/sites') +def sites(): + sites = Site.select().where(Site.last_download.is_null(False)) + data = {} + for site in sites: + data[site.url] = (model_to_dict(site)) + return jsonify(data) + + @app.route('/test') def sdfdsfds(): user = User.select().get() - + for question in Question.select(): + question.upvotes = 1 + question.downvotes = 1 + question.save() return jsonify( model_to_dict(Answer.select().where((Answer.question.is_null())).get())) -@app.route('/api/vote//', methods=["POST"]) +@app.route('/api/vote///', methods=["POST"]) @limiter.limit("10 per minute") -def vote(id, type): +def vote(type, id, vote): if "voted" not in session: voted = [] else: voted = session["voted"] print(voted) - if id in voted: + if (type, id) in voted: abort(403) - if type == "up": - query = Question.update(upvotes=Question.upvotes + 1).where(Question.id == id) - elif type == "down": - query = Question.update(downvotes=Question.downvotes + 1).where(Question.id == id) + if type == "question": + if vote == "up": + query = Question.update(upvotes=Question.upvotes + 1).where(Question.id == id) + elif vote == "down": + query = Question.update(downvotes=Question.downvotes + 1).where(Question.id == id) + else: + return abort(404) + elif type == "answer": + if vote == "up": + query = Answer.update(upvotes=Answer.upvotes + 1).where(Answer.id == id) + elif vote == "down": + query = Answer.update(downvotes=Answer.downvotes + 1).where(Answer.id == id) + else: + return abort(404) else: return abort(404) - voted.append(id) + voted.append((type, id)) session["voted"] = voted query.execute() - query = Question.select(Question.upvotes, Question.downvotes).where(Question.id == id).get() - + if type == "question": + query = Question.select(Question.upvotes, Question.downvotes).where(Question.id == id).get() + else: + query = Answer.select(Answer.upvotes, Answer.downvotes).where(Answer.id == id).get() return jsonify({ "upvotes": query.upvotes, "downvotes": query.downvotes @@ -98,21 +128,22 @@ def vote(id, type): @app.errorhandler(429) def ratelimit_handler(e): - return make_response( - jsonify(error="ratelimit exceeded {}".format(e.description)) - , 429 - ) + return make_response(jsonify(error="ratelimit exceeded {}".format(e.description)), 429) @app.errorhandler(403) def ratelimit_handler(e): - return make_response( - jsonify(error="access denied") - , 403 - ) + return make_response(jsonify(error="access denied"), 403) if __name__ == '__main__': + import logging + + logger = logging.getLogger('peewee') + logger.setLevel(logging.DEBUG) + logger.addHandler(logging.StreamHandler()) + + @app.route('/static/js/') def send_js(path): return send_from_directory('web/static/js', path) diff --git a/templates/base.html b/templates/base.html index 43fbd86..c7abd66 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,4 +15,6 @@ {% block body %}{% endblock %} + + diff --git a/templates/detail.html b/templates/detail.html index 0f6abe8..54ce856 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -1,27 +1,21 @@ {% extends "base.html" %} +{% from 'macros.html' import siteheader %} {% block body %} - - + {{ siteheader(question.site) }}

{{ question.title.text }}

-
-
+
+
{{ question.upvotes - question.downvotes }}
-
+
{% for paragraph in question.text.split("\n") %}

{{ paragraph }}

{% endfor %} -
+
+

{{ answers|length }} Answers

{% for answer in answers %} -
-
-
+
+
{{ answer.upvotes - answer.downvotes }}
- {% for paragraph in answer.text.split("\n") %} -

{{ paragraph }}

- {% endfor %} - +
+ {% for paragraph in answer.text.split("\n") %} +

{{ paragraph }}

+ {% endfor %} + +
{% endfor %} -
{{ debug|pprint(True) }}
{% endblock %} diff --git a/templates/list.html b/templates/list.html index e34e485..8f1b6e9 100644 --- a/templates/list.html +++ b/templates/list.html @@ -1,16 +1,24 @@ {% extends "base.html" %} -{% from 'macros.html' import pagination %} +{% from 'macros.html' import pagination, siteheader %} {% block body %} + {{ siteheader(site) }} + + + + {% if not site.fallback %} + + Clear filter + {% endif %} {{ pagination(pagearray, num_pages, page, True) }} {% for question in questions %} -
-
+
{{ question.upvotes - question.downvotes }}
-
+
{{ question.site.name }} diff --git a/templates/macros.html b/templates/macros.html index 0d30637..c345eae 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -42,3 +42,12 @@
{%- endmacro %} +{% macro siteheader(site) %} + +{% endmacro %} diff --git a/text_generator.py b/text_generator.py index a34a573..0ebe41e 100644 --- a/text_generator.py +++ b/text_generator.py @@ -74,7 +74,7 @@ def generate_text(chain: markovify.Text, model): if model == "Questions" or "Answers": paragraphs = [] sentences = [] - count = int((random.randint(2, 6) * random.randint(2, 6) / 5)) + count = int((random.randint(2, 6) * random.randint(3, 6) / 5)) for _ in range(count): sentences.append(chain.make_sentence()) if random.random() < 0.4: diff --git a/todb.py b/todb.py index 60888f8..0db1107 100644 --- a/todb.py +++ b/todb.py @@ -57,7 +57,6 @@ def add_question(site, count=100): num_answers = random.randint(1, 4) answers = Answer.select().where((Answer.site == site) & (Answer.question.is_null())).limit(num_answers) for answer in answers: - print("question {} goes to answer {}".format(answer.id, question.id)) answer.question = question answer.save() diff --git a/utils.py b/utils.py index a64bb93..3956281 100644 --- a/utils.py +++ b/utils.py @@ -98,4 +98,21 @@ def create_pagination(num_pages, page, padding=2): def rand(): - return random.randint(-2**31, 2**31-1) + return random.randint(-2 ** 31, 2 ** 31 - 1) + + +rating_sql = """ +((upvotes + 1.9208) / (upvotes + downvotes) - +1.96 * SQRT((upvotes * downvotes) / (upvotes + downvotes) + 0.9604) / +(upvotes + downvotes)) / (1 + 3.8416 / (upvotes + downvotes)) +AS ci_lower_bound +""" + + +def get_fallback_site(): + return { + "name": "Stack Exchange", + "url": "stackexchange.com/", + "icon_url": "https://cdn.sstatic.net/Sites/stackexchange/img/apple-touch-icon.png", + "fallback": True + } diff --git a/web/static/js/app.js b/web/static/js/app.js index 9fcfbe5..3343416 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -3,12 +3,14 @@ document.addEventListener("DOMContentLoaded", function (event) { console.warn(vote); Array.prototype.forEach.call(vote, function (elvote) { var id = elvote.dataset.id; + var type = elvote.dataset.type; Array.prototype.forEach.call(elvote.querySelectorAll("a"), function (el) { el.addEventListener("click", function (event) { - var type = el.classList[0]; - console.info(id, type); + console.info(elvote); + var vote = el.classList[0]; + console.info(type, id, vote); var request = new XMLHttpRequest(); - request.open("POST", "/api/vote/" + id + "/" + type, true); + request.open("POST", "/api/vote/" + type + "/" + id + "/" + vote, true); request.onload = function () { if (this.status >= 200 && this.status < 400) { @@ -28,5 +30,44 @@ document.addEventListener("DOMContentLoaded", function (event) { }) }); }); + var input = document.getElementById("siteselector"); + var request = new XMLHttpRequest(); + request.open("GET", "/api/sites", true); + + request.onload = function () { + if (this.status >= 200 && this.status < 400) { + var resp = JSON.parse(this.response); + var list = []; + for (var key in resp) { + if (resp.hasOwnProperty(key)) { + var site, shortname; + site = resp[key]; + shortname = site.url.replace(".stackexchange.com", ".SE"); + list.push({ + label: site.name + " (" + shortname + ")", + value: site.url + }); + } + } + new Awesomplete(input, { + list: list + }); + input.addEventListener("awesomplete-select", function (event) { + if (!(event.text.value in resp)) { // shouldn't happen + return false + } + var selectedSite=resp[event.text.value]; + window.location.href="/s/"+selectedSite.url + + }); + } else { + // We reached our target server, but it returned an error + + } + }; + request.onerror = function () { + // There was a connection error of some sort + }; + request.send(); }); diff --git a/web/static/js/awesomplete.min.js b/web/static/js/awesomplete.min.js new file mode 100644 index 0000000..1939370 --- /dev/null +++ b/web/static/js/awesomplete.min.js @@ -0,0 +1,3 @@ +// Awesomplete - Lea Verou - MIT license +!function(){function t(t){var e=Array.isArray(t)?{label:t[0],value:t[1]}:"object"==typeof t&&"label"in t&&"value"in t?t:{label:t,value:t};this.label=e.label||e.value,this.value=e.value}function e(t,e,i){for(var n in e){var s=e[n],r=t.input.getAttribute("data-"+n.toLowerCase());"number"==typeof s?t[n]=parseInt(r):!1===s?t[n]=null!==r:s instanceof Function?t[n]=null:t[n]=r,t[n]||0===t[n]||(t[n]=n in i?i[n]:s)}}function i(t,e){return"string"==typeof t?(e||document).querySelector(t):t||null}function n(t,e){return o.call((e||document).querySelectorAll(t))}function s(){n("input.awesomplete").forEach(function(t){new r(t)})}var r=function(t,n){var s=this;Awesomplete.count=(Awesomplete.count||0)+1,this.count=Awesomplete.count,this.isOpened=!1,this.input=i(t),this.input.setAttribute("autocomplete","off"),this.input.setAttribute("aria-owns","awesomplete_list_"+this.count),this.input.setAttribute("role","combobox"),n=n||{},e(this,{minChars:2,maxItems:10,autoFirst:!1,data:r.DATA,filter:r.FILTER_CONTAINS,sort:!1!==n.sort&&r.SORT_BYLENGTH,item:r.ITEM,replace:r.REPLACE},n),this.index=-1,this.container=i.create("div",{className:"awesomplete",around:t}),this.ul=i.create("ul",{hidden:"hidden",role:"listbox",id:"awesomplete_list_"+this.count,inside:this.container}),this.status=i.create("span",{className:"visually-hidden",role:"status","aria-live":"assertive","aria-atomic":!0,inside:this.container,textContent:0!=this.minChars?"Type "+this.minChars+" or more characters for results.":"Begin typing for results."}),this._events={input:{input:this.evaluate.bind(this),blur:this.close.bind(this,{reason:"blur"}),keydown:function(t){var e=t.keyCode;s.opened&&(13===e&&s.selected?(t.preventDefault(),s.select()):27===e?s.close({reason:"esc"}):38!==e&&40!==e||(t.preventDefault(),s[38===e?"previous":"next"]()))}},form:{submit:this.close.bind(this,{reason:"submit"})},ul:{mousedown:function(t){var e=t.target;if(e!==this){for(;e&&!/li/i.test(e.nodeName);)e=e.parentNode;e&&0===t.button&&(t.preventDefault(),s.select(e,t.target))}}}},i.bind(this.input,this._events.input),i.bind(this.input.form,this._events.form),i.bind(this.ul,this._events.ul),this.input.hasAttribute("list")?(this.list="#"+this.input.getAttribute("list"),this.input.removeAttribute("list")):this.list=this.input.getAttribute("data-list")||n.list||[],r.all.push(this)};r.prototype={set list(t){if(Array.isArray(t))this._list=t;else if("string"==typeof t&&t.indexOf(",")>-1)this._list=t.split(/\s*,\s*/);else if((t=i(t))&&t.children){var e=[];o.apply(t.children).forEach(function(t){if(!t.disabled){var i=t.textContent.trim(),n=t.value||i,s=t.label||i;""!==n&&e.push({label:s,value:n})}}),this._list=e}document.activeElement===this.input&&this.evaluate()},get selected(){return this.index>-1},get opened(){return this.isOpened},close:function(t){this.opened&&(this.ul.setAttribute("hidden",""),this.isOpened=!1,this.index=-1,this.status.setAttribute("hidden",""),i.fire(this.input,"awesomplete-close",t||{}))},open:function(){this.ul.removeAttribute("hidden"),this.isOpened=!0,this.status.removeAttribute("hidden"),this.autoFirst&&-1===this.index&&this.goto(0),i.fire(this.input,"awesomplete-open")},destroy:function(){i.unbind(this.input,this._events.input),i.unbind(this.input.form,this._events.form);var t=this.container.parentNode;t.insertBefore(this.input,this.container),t.removeChild(this.container),this.input.removeAttribute("autocomplete"),this.input.removeAttribute("aria-autocomplete");var e=r.all.indexOf(this);-1!==e&&r.all.splice(e,1)},next:function(){var t=this.ul.children.length;this.goto(this.index-1&&e.length>0&&(e[t].setAttribute("aria-selected","true"),this.status.textContent=e[t].textContent+", list item "+(t+1)+" of "+e.length,this.input.setAttribute("aria-activedescendant",this.ul.id+"_item_"+this.index),this.ul.scrollTop=e[t].offsetTop-this.ul.clientHeight+e[t].clientHeight,i.fire(this.input,"awesomplete-highlight",{text:this.suggestions[this.index]}))},select:function(t,e){if(t?this.index=i.siblingIndex(t):t=this.ul.children[this.index],t){var n=this.suggestions[this.index];i.fire(this.input,"awesomplete-select",{text:n,origin:e||t})&&(this.replace(n),this.close({reason:"select"}),i.fire(this.input,"awesomplete-selectcomplete",{text:n}))}},evaluate:function(){var e=this,i=this.input.value;i.length>=this.minChars&&this._list.length>0?(this.index=-1,this.ul.innerHTML="",this.suggestions=this._list.map(function(n){return new t(e.data(n,i))}).filter(function(t){return e.filter(t,i)}),!1!==this.sort&&(this.suggestions=this.suggestions.sort(this.sort)),this.suggestions=this.suggestions.slice(0,this.maxItems),this.suggestions.forEach(function(t,n){e.ul.appendChild(e.item(t,i,n))}),0===this.ul.children.length?(this.status.textContent="No results found",this.close({reason:"nomatches"})):(this.open(),this.status.textContent=this.ul.children.length+" results found")):(this.close({reason:"nomatches"}),this.status.textContent="No results found")}},r.all=[],r.FILTER_CONTAINS=function(t,e){return RegExp(i.regExpEscape(e.trim()),"i").test(t)},r.FILTER_STARTSWITH=function(t,e){return RegExp("^"+i.regExpEscape(e.trim()),"i").test(t)},r.SORT_BYLENGTH=function(t,e){return t.length!==e.length?t.length-e.length:t$&"),"aria-selected":"false",id:"awesomplete_list_"+this.count+"_item_"+n})},r.REPLACE=function(t){this.input.value=t.value},r.DATA=function(t){return t},Object.defineProperty(t.prototype=Object.create(String.prototype),"length",{get:function(){return this.label.length}}),t.prototype.toString=t.prototype.valueOf=function(){return""+this.label};var o=Array.prototype.slice;i.create=function(t,e){var n=document.createElement(t);for(var s in e){var r=e[s];if("inside"===s)i(r).appendChild(n);else if("around"===s){var o=i(r);o.parentNode.insertBefore(n,o),n.appendChild(o)}else s in n?n[s]=r:n.setAttribute(s,r)}return n},i.bind=function(t,e){if(t)for(var i in e){var n=e[i];i.split(/\s+/).forEach(function(e){t.addEventListener(e,n)})}},i.unbind=function(t,e){if(t)for(var i in e){var n=e[i];i.split(/\s+/).forEach(function(e){t.removeEventListener(e,n)})}},i.fire=function(t,e,i){var n=document.createEvent("HTMLEvents");n.initEvent(e,!0,!0);for(var s in i)n[s]=i[s];return t.dispatchEvent(n)},i.regExpEscape=function(t){return t.replace(/[-\\^$*+?.()|[\]{}]/g,"\\$&")},i.siblingIndex=function(t){for(var e=0;t=t.previousElementSibling;e++);return e},"undefined"!=typeof Document&&("loading"!==document.readyState?s():document.addEventListener("DOMContentLoaded",s)),r.$=i,r.$$=n,"undefined"!=typeof self&&(self.Awesomplete=r),"object"==typeof module&&module.exports&&(module.exports=r)}(); +//# sourceMappingURL=awesomplete.min.js.map diff --git a/web/static/sass/_awesomplete.base.scss b/web/static/sass/_awesomplete.base.scss new file mode 100644 index 0000000..8e5ec12 --- /dev/null +++ b/web/static/sass/_awesomplete.base.scss @@ -0,0 +1,33 @@ +.awesomplete [hidden] { + display: none; +} + +.awesomplete .visually-hidden { + position: absolute; + clip: rect(0, 0, 0, 0); +} + +.awesomplete { + display: inline-block; + position: relative; +} + +.awesomplete > input { + display: block; +} + +.awesomplete > ul { + position: absolute; + left: 0; + z-index: 1; + min-width: 100%; + box-sizing: border-box; + list-style: none; + padding: 0; + margin: 0; + background: #fff; +} + +.awesomplete > ul:empty { + display: none; +} diff --git a/web/static/sass/style.scss b/web/static/sass/style.scss index 37c2d10..985965b 100644 --- a/web/static/sass/style.scss +++ b/web/static/sass/style.scss @@ -2,11 +2,12 @@ $link-color: #07C; $link-hover-color: #3af; @import "../../milligram/src/milligram"; - +@import "awesomplete.base"; @import "pagination"; body { font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + font-size: 15px; color: #111; } @@ -14,15 +15,17 @@ pre > code { white-space: pre-wrap; } -.question { +.content { position: relative; display: flex; flex-direction: row; - padding: 15px 5px 30px; - border-bottom: solid lightgray 1px; + padding: 15px 5px 15px; + &:not(.question){ + border-bottom: solid lightgray 1px; + } border-right: solid 10px transparent; - .questionbox { + .contentbox { margin-left: 10px; width: 100%; .sitename { @@ -34,14 +37,14 @@ pre > code { text-decoration: underline; } } - .date{ + .date { position: absolute; right: 5px; bottom: 5px; font-size: 12px; color: grey; } - .questionfooter { + .contentfooter { display: flex; justify-content: right; margin: 0; @@ -81,27 +84,28 @@ pre > code { border-width: 0 15px 15px 15px; border-color: transparent transparent #858c93 transparent; &:hover, &:focus, &.active { - border-color: transparent transparent darken(#858c93,20%) transparent; + border-color: transparent transparent darken(#858c93, 20%) transparent; } } .down { border-width: 15px 15px 0 15px; border-color: #858c93 transparent transparent transparent; &:hover, &:focus, &.active { - border-color: darken(#858c93,20%) transparent transparent transparent; + border-color: darken(#858c93, 20%) transparent transparent transparent; } } } } -h1{ +h1, h2 { border-bottom: solid 1px lightgray; padding-bottom: 10px; margin-bottom: 0; margin-top: 10px; color: inherit; } +h2 {font-size: 18px} .siteheader { background: #ebf2f5;