Add review voting

This commit is contained in:
rubenwardy 2021-08-18 22:09:41 +01:00
parent 4a1f654798
commit fab814c46f
14 changed files with 177 additions and 28 deletions

View File

@ -21,8 +21,8 @@ from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType
from app.utils import is_package_page, addNotification, get_int_or_abort
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url
from app.tasks.webhooktasks import post_discord_webhook
@ -146,3 +146,43 @@ def delete_review(package):
db.session.commit()
return redirect(thread.getViewURL())
def handle_review_vote(package: Package, review_id: int):
if current_user in package.maintainers:
flash("You can't vote on the reviews on your own package!", "danger")
return
review: PackageReview = PackageReview.query.get(review_id)
if review is None or review.package != package:
abort(404)
if review.author == current_user:
flash("You can't vote on your own reviews!", "danger")
return
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
if vote is None:
vote = PackageReviewVote()
vote.review = review
vote.user = current_user
vote.is_positive = isYes(request.form["is_positive"])
db.session.add(vote)
else:
vote.is_positive = isYes(request.form["is_positive"])
review.update_score()
db.session.commit()
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
@login_required
@is_package_page
def review_vote(package, review_id):
handle_review_vote(package, review_id)
next_url = request.args.get("r")
if next_url and is_safe_url(next_url):
return redirect(next_url)
else:
return redirect(review.thread.getViewURL())

View File

@ -15,12 +15,12 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import math
from typing import List, Optional, Tuple
from typing import Optional
from flask import *
from flask_babel import gettext
from flask_login import current_user, login_required
from sqlalchemy import func, and_, or_
from sqlalchemy import func
from app.models import *
from app.tasks.forumtasks import checkForumAccount
@ -88,49 +88,51 @@ def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
# REVIEWS
#
users_by_reviews = db.session.query(User.username, func.count(PackageReview.id).label("count")) \
users_by_reviews = db.session.query(User.username, func.sum(PackageReview.score).label("karma")) \
.select_from(User).join(PackageReview) \
.group_by(User.username).order_by(text("count DESC")).all()
.group_by(User.username).order_by(text("karma DESC")).all()
try:
review_boundary = users_by_reviews[math.floor(len(users_by_reviews) * 0.25)][1] + 1
except IndexError:
review_boundary = None
users_by_reviews = [username for username, _ in users_by_reviews]
usernames_by_reviews = [username for username, _ in users_by_reviews]
review_idx = None
review_percent = None
review_karma = 0
try:
review_idx = users_by_reviews.index(user.username)
review_idx = usernames_by_reviews.index(user.username)
review_percent = round(100 * review_idx / len(users_by_reviews), 1)
review_karma = max(users_by_reviews[review_idx][1], 0)
except ValueError:
pass
if review_percent is not None and review_percent < 25:
if review_idx == 0:
title = gettext(u"Most reviews")
title = gettext(u"Top reviewer")
description = gettext(
u"%(display_name)s has written the most reviews on ContentDB.",
u"%(display_name)s has written the most helpful reviews on ContentDB.",
display_name=user.display_name)
elif review_idx <= 2:
if review_idx == 1:
title = gettext(u"2nd most reviews")
title = gettext(u"2nd most helpful reviewer")
else:
title = gettext(u"3rd most reviews")
title = gettext(u"3rd most helpful reviewer")
description = gettext(
u"This puts %(display_name)s in the top %(perc)s%%",
display_name=user.display_name, perc=review_percent)
else:
title = gettext(u"Top %(perc)s%% reviewer", perc=review_percent)
description = gettext(u"Only %(place)d users have written more reviews.", place=review_idx)
description = gettext(u"Only %(place)d users have written more helpful reviews.", place=review_idx)
unlocked.append(Medal.make_unlocked(
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
else:
description = gettext(u"Consider writing more reviews to get a medal.")
description = gettext(u"Consider writing more helpful reviews to get a medal.")
if review_idx:
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
locked.append(Medal.make_locked(
description, (len(user.reviews), review_boundary)))
description, (review_karma, review_boundary)))
#
# TOP PACKAGES

View File

@ -337,7 +337,8 @@ class Package(db.Model):
threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"),
foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic")
reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at"),
reviews = db.relationship("PackageReview", back_populates="package",
order_by=[db.desc("package_review_score"),db.desc("package_review_created_at")],
cascade="all, delete, delete-orphan")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id",

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from typing import Tuple, List
from flask import url_for
@ -156,6 +157,16 @@ class PackageReview(db.Model):
recommends = db.Column(db.Boolean, nullable=False)
thread = db.relationship("Thread", uselist=False, back_populates="review")
votes = db.relationship("PackageReviewVote", back_populates="review")
score = db.Column(db.Integer, nullable=False, default=1)
def get_totals(self, current_user = None) -> Tuple[int,int,bool]:
votes: List[PackageReviewVote] = self.votes
pos = sum([ 1 for vote in votes if vote.is_positive ])
neg = sum([ 1 for vote in votes if not vote.is_positive])
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
return pos, neg, user_vote.is_positive if user_vote else None
def asSign(self):
return 1 if self.recommends else -1
@ -167,3 +178,25 @@ class PackageReview(db.Model):
return url_for("packages.delete_review",
author=self.package.author.username,
name=self.package.name)
def getVoteUrl(self, next_url=None):
return url_for("packages.review_vote",
author=self.package.author.username,
name=self.package.name,
review_id=self.id,
r=next_url)
def update_score(self):
(pos, neg, _) = self.get_totals()
self.score = 3 * (pos - neg) + 1
class PackageReviewVote(db.Model):
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
is_positive = db.Column(db.Boolean, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)

View File

@ -172,6 +172,7 @@ class User(db.Model, UserMixin):
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")

View File

@ -1,6 +1,6 @@
from . import app, utils
from .models import Permission, Package, PackageState, PackageRelease
from .utils import abs_url_for, url_set_query
from .utils import abs_url_for, url_set_query, url_set_anchor
from flask_login import current_user
from flask_babel import format_timedelta, gettext
from urllib.parse import urlparse
@ -16,9 +16,8 @@ def inject_debug():
@app.context_processor
def inject_functions():
check_global_perm = Permission.checkPerm
return dict(abs_url_for=abs_url_for, url_set_query=url_set_query,
check_global_perm=check_global_perm,
get_headings=get_headings)
return dict(abs_url_for=abs_url_for, url_set_query=url_set_query, url_set_anchor=url_set_anchor,
check_global_perm=check_global_perm, get_headings=get_headings)
@app.context_processor
def inject_todo():

View File

@ -152,7 +152,7 @@
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Recent Positive Reviews") }}</h2>
{% from "macros/reviews.html" import render_reviews %}
{% from "macros/reviews.html" import render_reviews with context %}
{{ render_reviews(reviews, current_user, True) }}

View File

@ -1,7 +1,28 @@
{% macro render_review_vote(review, current_user, next_url) %}
{% set (positive, negative, is_positive) = review.get_totals(current_user) %}
<form class="-group" method="post" action="{{ review.getVoteUrl(next_url) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="btn-group">
<button class="btn {% if is_positive == true %}btn-primary{% else %}btn-secondary{% endif %}" name="is_positive" value="yes">
<i class="fas fa-thumbs-up mr-1"></i>
{{ _("Helpful") }}
<span class="badge badge-light ml-1">{{ positive }}</span>
</button>
<button class="btn {% if is_positive == false %}btn-primary{% else %}btn-secondary{% endif %}" name="is_positive" value="no">
<i class="fas fa-thumbs-down mr-1"></i>
{{ _("Unhelpful") }}
<span class="badge badge-light ml-1">{{ negative }}</span>
</button>
</div>
</form>
{% endmacro %}
{% macro render_reviews(reviews, current_user, show_package_link=False) -%}
<ul class="comments mt-4 mb-0">
{% for review in reviews %}
{% set review_anchor = "review-" + (review.id | string) %}
<li class="row my-2 mx-0">
<a id="{{ review_anchor }}"></a>
<div class="col-md-1 p-1">
<a href="{{ url_for('users.profile', username=review.author.username) }}">
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ review.author.getProfilePicURL() }}">
@ -44,7 +65,7 @@
{{ reply.comment | markdown }}
<p class="mt-2 mb-0">
<div class="btn-toolbar mt-2 mb-0">
{% if show_package_link %}
<a class="btn btn-primary mr-1" href="{{ review.package.getURL("packages.view") }}">
{{ _("%(title)s by %(author)s",
@ -53,12 +74,14 @@
</a>
{% endif %}
<a class="btn {% if review.thread.replies.count() > 1 %} btn-primary {% else %} btn-secondary {% endif %}"
<a class="btn {% if review.thread.replies.count() > 1 %} btn-primary {% else %} btn-secondary {% endif %} mr-1"
href="{{ url_for('threads.view', id=review.thread.id) }}">
<i class="fas fa-comments mr-2"></i>
{{ _("%(num)d comments", num=review.thread.replies.count() - 1) }}
</a>
</p>
{{ render_review_vote(review, current_user, url_set_anchor(review_anchor)) }}
</div>
</div>
</div>
</div>

View File

@ -1,5 +1,7 @@
{% macro render_thread(thread, current_user) -%}
{% from "macros/reviews.html" import render_review_vote %}
<ul class="comments mt-4 mb-0">
{% for r in thread.replies %}
<li class="row my-2 mx-0">
@ -60,6 +62,10 @@
{% endif %}
{{ r.comment | markdown }}
{% if thread.replies[0] == r and thread.review %}
{{ render_review_vote(thread.review, current_user, thread.getViewURL()) }}
{% endif %}
</div>
</div>
</div>

View File

@ -5,8 +5,8 @@
{% endblock %}
{% block content %}
{% from "macros/pagination.html" import render_pagination %}
{% from "macros/reviews.html" import render_reviews %}
{% from "macros/pagination.html" import render_pagination with context %}
{% from "macros/reviews.html" import render_reviews with context %}
{{ render_pagination(pagination, url_set_query) }}
{{ render_reviews(reviews, current_user, True) }}

View File

@ -253,7 +253,7 @@
<h2 id="reviews" class="mt-0">{{ _("Reviews") }}</h2>
{% from "macros/reviews.html" import render_reviews, render_review_form, render_review_preview %}
{% from "macros/reviews.html" import render_reviews, render_review_form, render_review_preview with context %}
{% if current_user.is_authenticated %}
{% if has_review %}
<p>

View File

@ -191,7 +191,7 @@
<h2 class="my-3" id="reviews">{{ _("Reviews") }}</h2>
{% from "macros/reviews.html" import render_reviews %}
{% from "macros/reviews.html" import render_reviews with context %}
{{ render_reviews(user.reviews, current_user, True) }}
{% endblock %}

View File

@ -40,6 +40,12 @@ def abs_url_for(path, **kwargs):
def abs_url(path):
return urljoin(app.config["BASE_URL"], path)
def url_set_anchor(anchor):
args = MultiDict(request.args)
dargs = dict(args.lists())
dargs.update(request.view_args)
return url_for(request.endpoint, **dargs) + "#" + anchor
def url_set_query(**kwargs):
args = MultiDict(request.args)

View File

@ -0,0 +1,38 @@
"""empty message
Revision ID: cd5ab8a01f4a
Revises: 1af840af0209
Create Date: 2021-08-18 20:47:54.268263
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'cd5ab8a01f4a'
down_revision = '1af840af0209'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('package_review_vote',
sa.Column('review_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_positive', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['review_id'], ['package_review.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('review_id', 'user_id')
)
op.add_column('package_review', sa.Column('score', sa.Integer(), nullable=False, server_default="1"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('package_review', 'score')
op.drop_table('package_review_vote')
# ### end Alembic commands ###