diff --git a/app/blueprints/packages/reviews.py b/app/blueprints/packages/reviews.py index 0d8c5e0..b902310 100644 --- a/app/blueprints/packages/reviews.py +++ b/app/blueprints/packages/reviews.py @@ -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///review//", 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()) diff --git a/app/blueprints/users/profile.py b/app/blueprints/users/profile.py index 9259b8a..8ffa3f1 100644 --- a/app/blueprints/users/profile.py +++ b/app/blueprints/users/profile.py @@ -15,12 +15,12 @@ # along with this program. If not, see . 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 diff --git a/app/models/packages.py b/app/models/packages.py index a9b9e77..f1231c8 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -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", diff --git a/app/models/threads.py b/app/models/threads.py index 13dae75..8b731ef 100644 --- a/app/models/threads.py +++ b/app/models/threads.py @@ -15,6 +15,7 @@ # along with this program. If not, see . 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) diff --git a/app/models/users.py b/app/models/users.py index 81792d4..2be63f5 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -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") diff --git a/app/template_filters.py b/app/template_filters.py index 154a5b2..828c03a 100644 --- a/app/template_filters.py +++ b/app/template_filters.py @@ -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(): diff --git a/app/templates/index.html b/app/templates/index.html index 1f6981f..b0ba3b3 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -152,7 +152,7 @@ {{ _("See more") }}

{{ _("Recent Positive Reviews") }}

- {% from "macros/reviews.html" import render_reviews %} + {% from "macros/reviews.html" import render_reviews with context %} {{ render_reviews(reviews, current_user, True) }} diff --git a/app/templates/macros/reviews.html b/app/templates/macros/reviews.html index 249d1bb..bb6b662 100644 --- a/app/templates/macros/reviews.html +++ b/app/templates/macros/reviews.html @@ -1,7 +1,28 @@ +{% macro render_review_vote(review, current_user, next_url) %} + {% set (positive, negative, is_positive) = review.get_totals(current_user) %} +
+ +
+ + +
+
+{% endmacro %} + {% macro render_reviews(reviews, current_user, show_package_link=False) -%}
    {% for review in reviews %} + {% set review_anchor = "review-" + (review.id | string) %}
  • + diff --git a/app/templates/macros/threads.html b/app/templates/macros/threads.html index d9e73e9..19a7717 100644 --- a/app/templates/macros/threads.html +++ b/app/templates/macros/threads.html @@ -1,5 +1,7 @@ {% macro render_thread(thread, current_user) -%} +{% from "macros/reviews.html" import render_review_vote %} +
      {% for r in thread.replies %}
    • @@ -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 %} diff --git a/app/templates/packages/reviews_list.html b/app/templates/packages/reviews_list.html index fe69fa6..0ff71c0 100644 --- a/app/templates/packages/reviews_list.html +++ b/app/templates/packages/reviews_list.html @@ -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) }} diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html index 422facf..1de5fac 100644 --- a/app/templates/packages/view.html +++ b/app/templates/packages/view.html @@ -253,7 +253,7 @@

      {{ _("Reviews") }}

      - {% 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 %}

      diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html index d556869..00f5e83 100644 --- a/app/templates/users/profile.html +++ b/app/templates/users/profile.html @@ -191,7 +191,7 @@

      {{ _("Reviews") }}

      -{% from "macros/reviews.html" import render_reviews %} +{% from "macros/reviews.html" import render_reviews with context %} {{ render_reviews(user.reviews, current_user, True) }} {% endblock %} diff --git a/app/utils/flask.py b/app/utils/flask.py index 76bf31e..9307ed2 100644 --- a/app/utils/flask.py +++ b/app/utils/flask.py @@ -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) diff --git a/migrations/versions/cd5ab8a01f4a_.py b/migrations/versions/cd5ab8a01f4a_.py new file mode 100644 index 0000000..afe4455 --- /dev/null +++ b/migrations/versions/cd5ab8a01f4a_.py @@ -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 ###