From b1c349cc3558b4642a700a489482ff95b038ce56 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Mon, 11 Jun 2018 22:49:25 +0100 Subject: [PATCH] Add comment system --- app/models.py | 49 ++++++++++ app/templates/macros/threads.html | 27 ++++++ app/templates/packages/view.html | 13 +++ app/templates/threads/list.html | 12 +++ app/templates/threads/new.html | 19 ++++ app/templates/threads/view.html | 25 +++++ app/views/__init__.py | 2 +- app/views/packages/__init__.py | 8 +- app/views/threads.py | 136 +++++++++++++++++++++++++++ migrations/versions/605b3d74ada1_.py | 55 +++++++++++ 10 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 app/templates/macros/threads.html create mode 100644 app/templates/threads/list.html create mode 100644 app/templates/threads/new.html create mode 100644 app/templates/threads/view.html create mode 100644 app/views/threads.py create mode 100644 migrations/versions/605b3d74ada1_.py diff --git a/app/models.py b/app/models.py index 7f0d8f3..c76cb25 100644 --- a/app/models.py +++ b/app/models.py @@ -76,6 +76,7 @@ class Permission(enum.Enum): CHANGE_RANK = "CHANGE_RANK" CHANGE_EMAIL = "CHANGE_EMAIL" EDIT_EDITREQUEST = "EDIT_EDITREQUEST" + SEE_THREAD = "SEE_THREAD" # Only return true if the permission is valid for *all* contexts # See Package.checkPerm for package-specific contexts @@ -120,6 +121,8 @@ class User(db.Model, UserMixin): # causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic") packages = db.relationship("Package", backref="author", lazy="dynamic") requests = db.relationship("EditRequest", backref="author", lazy="dynamic") + threads = db.relationship("Thread", backref="author", lazy="dynamic") + replies = db.relationship("ThreadReply", backref="author", lazy="dynamic") def __init__(self, username, active=False, email=None, password=None): import datetime @@ -337,6 +340,9 @@ class Package(db.Model): approved = db.Column(db.Boolean, nullable=False, default=False) soft_deleted = db.Column(db.Boolean, nullable=False, default=False) + review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None) + review_thread = db.relationship("Thread", foreign_keys=[review_thread_id]) + # Downloads repo = db.Column(db.String(200), nullable=True) website = db.Column(db.String(200), nullable=True) @@ -659,6 +665,49 @@ class EditRequestChange(db.Model): setattr(package, self.key.name, self.newValue) +class Thread(db.Model): + id = db.Column(db.Integer, primary_key=True) + + package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) + package = db.relationship("Package", foreign_keys=[package_id]) + + author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + title = db.Column(db.String(100), nullable=False) + private = db.Column(db.Boolean, server_default="0") + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + replies = db.relationship("ThreadReply", backref="thread", lazy="dynamic") + + def checkPerm(self, user, perm): + if not user.is_authenticated: + return not self.private + + if type(perm) == str: + perm = Permission[perm] + elif type(perm) != Permission: + raise Exception("Unknown permission given to Thread.checkPerm()") + + isOwner = user == self.author + + if perm == Permission.SEE_THREAD: + return not self.private or isOwner or user.rank.atLeast(UserRank.EDITOR) + + else: + raise Exception("Permission {} is not related to threads".format(perm.name)) + +class ThreadReply(db.Model): + id = db.Column(db.Integer, primary_key=True) + thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False) + comment = db.Column(db.String(500), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + + + + + REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \ "minetest.net", "dropboxusercontent.com", "4shared.com", \ "digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", \ diff --git a/app/templates/macros/threads.html b/app/templates/macros/threads.html new file mode 100644 index 0000000..96e1107 --- /dev/null +++ b/app/templates/macros/threads.html @@ -0,0 +1,27 @@ +{% macro render_thread(thread, current_user) -%} + + + {% if current_user.is_authenticated %} +
+ +
+ +
+ {% endif %} +{% endmacro %} + +{% macro render_threadlist(threads) -%} + + +{% endmacro %} diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html index 56cfd62..3a83995 100644 --- a/app/templates/packages/view.html +++ b/app/templates/packages/view.html @@ -43,6 +43,19 @@ {% endif %}
+ + {% if package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW") %} + {% if review_thread %} + {% from "macros/threads.html" import render_thread %} + {{ render_thread(review_thread, current_user) }} + {% else %} +
+ Privately ask a question or give feedback + + Open Thread +
+ {% endif %} + {% endif %} {% endif %}

{{ package.title }} by {{ package.author.display_name }}

diff --git a/app/templates/threads/list.html b/app/templates/threads/list.html new file mode 100644 index 0000000..7e27325 --- /dev/null +++ b/app/templates/threads/list.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %} +Threads +{% endblock %} + +{% block content %} +

Threads

+ + {% from "macros/threads.html" import render_threadlist %} + {{ render_threadlist(threads) }} +{% endblock %} diff --git a/app/templates/threads/new.html b/app/templates/threads/new.html new file mode 100644 index 0000000..22f5b72 --- /dev/null +++ b/app/templates/threads/new.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %} + New Thread +{% endblock %} + +{% block content %} + {% from "macros/forms.html" import render_field, render_submit_field %} +
+ {{ form.hidden_tag() }} + + {{ render_field(form.title) }} + {{ render_field(form.comment) }} + {{ render_field(form.private) }} + {{ render_submit_field(form.submit) }} + +

Only the you, the package author, and users of Editor rank and above can read private threads.

+
+{% endblock %} diff --git a/app/templates/threads/view.html b/app/templates/threads/view.html new file mode 100644 index 0000000..397fba3 --- /dev/null +++ b/app/templates/threads/view.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %} +Threads +{% endblock %} + +{% block content %} +

{% if thread.private %}🔒 {% endif %}{{ thread.title }}

+ + {% if thread.package %} +

+ Package: {{ thread.package.title }} +

+ {% endif %} + + {% if thread.private %} + + This thread is only visible to its creator, the package owner, and users of + Editor rank or above. + + {% endif %} + + {% from "macros/threads.html" import render_thread %} + {{ render_thread(thread, current_user) }} +{% endblock %} diff --git a/app/views/__init__.py b/app/views/__init__.py index 09c7be8..5257675 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -51,7 +51,7 @@ def home_page(): packages = query.order_by(db.desc(Package.created_at)).limit(15).all() return render_template("index.html", packages=packages, count=count) -from . import users, githublogin, packages, sass, tasks, admin, notifications, tagseditor, meta, thumbnails +from . import users, githublogin, packages, sass, tasks, admin, notifications, tagseditor, meta, thumbnails, threads @menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { 'path': 'help' }) @app.route('//') diff --git a/app/views/packages/__init__.py b/app/views/packages/__init__.py index 07f529b..0c22a70 100644 --- a/app/views/packages/__init__.py +++ b/app/views/packages/__init__.py @@ -110,9 +110,15 @@ def package_page(package): releases = getReleases(package) requests = [r for r in package.requests if r.status == 0] + + review_thread = Thread.query.filter_by(package_id=package.id, private=True).first() + if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD): + review_thread = None + return render_template("packages/view.html", \ package=package, releases=releases, requests=requests, \ - alternatives=alternatives, similar_topics=similar_topics) + alternatives=alternatives, similar_topics=similar_topics, \ + review_thread=review_thread) @app.route("/packages///download/") diff --git a/app/views/threads.py b/app/views/threads.py new file mode 100644 index 0000000..e4973a5 --- /dev/null +++ b/app/views/threads.py @@ -0,0 +1,136 @@ +# Content DB +# Copyright (C) 2018 rubenwardy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from flask import * +from flask_user import * +from app import app +from app.models import * +from app.utils import triggerNotif, clearNotifications + +from flask_wtf import FlaskForm +from wtforms import * +from wtforms.validators import * + +@app.route("/threads/") +def threads_page(): + threads = Thread.query.filter_by(private=False).all() + return render_template("threads/list.html", threads=threads) + +@app.route("/threads//", methods=["GET", "POST"]) +def thread_page(id): + clearNotifications(url_for("thread_page", id=id)) + + thread = Thread.query.get(id) + if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD): + abort(404) + + if current_user.is_authenticated and request.method == "POST": + comment = request.form["comment"] + + if len(comment) <= 500 and len(comment) > 3: + reply = ThreadReply() + reply.author = current_user + reply.comment = comment + db.session.add(reply) + + thread.replies.append(reply) + db.session.commit() + + return redirect(url_for("thread_page", id=id)) + + else: + flash("Comment needs to be between 3 and 500 characters.") + + return render_template("threads/view.html", thread=thread) + + +class ThreadForm(FlaskForm): + title = StringField("Title", [InputRequired(), Length(3,100)]) + comment = TextAreaField("Comment", [InputRequired(), Length(10, 500)]) + private = BooleanField("Private") + submit = SubmitField("Open Thread") + +@app.route("/threads/new/", methods=["GET", "POST"]) +@login_required +def new_thread_page(): + form = ThreadForm(formdata=request.form) + + package = None + if "pid" in request.args: + package = Package.query.get(int(request.args.get("pid"))) + if package is None: + flash("Unable to find that package!", "error") + + if package is None: + abort(403) + + def_is_private = request.args.get("private") or False + if not package.approved: + def_is_private = True + allow_change = package.approved + is_review_thread = package is not None and not package.approved + + # Check that user can make the thread + if is_review_thread and not (package.author == current_user or \ + package.checkPerm(current_user, Permission.APPROVE_NEW)): + flash("Unable to create thread!", "error") + return redirect(url_for("home_page")) + + # Only allow creating one thread when not approved + elif is_review_thread and package.review_thread is not None: + flash("A review thread already exists!", "error") + if request.method == "GET": + return redirect(url_for("thread_page", id=package.review_thread.id)) + + # Set default values + elif request.method == "GET": + form.private.data = def_is_private + form.title.data = request.args.get("title") or "" + + # Validate and submit + elif request.method == "POST" and form.validate(): + thread = Thread() + thread.author = current_user + thread.title = form.title.data + thread.private = form.private.data if allow_change else def_is_private + thread.package = package + db.session.add(thread) + + reply = ThreadReply() + reply.thread = thread + reply.author = current_user + reply.comment = form.comment.data + db.session.add(reply) + + thread.replies.append(reply) + + db.session.commit() + + if is_review_thread: + package.review_thread = thread + + if package is not None: + triggerNotif(package.author, current_user, + "New thread '{}' on package {}".format(thread.title, package.title), url_for("thread_page", id=thread.id)) + db.session.commit() + + db.session.commit() + + return redirect(url_for("thread_page", id=thread.id)) + + + return render_template("threads/new.html", form=form, allow_private_change=allow_change) diff --git a/migrations/versions/605b3d74ada1_.py b/migrations/versions/605b3d74ada1_.py new file mode 100644 index 0000000..acd0f2c --- /dev/null +++ b/migrations/versions/605b3d74ada1_.py @@ -0,0 +1,55 @@ +"""empty message + +Revision ID: 605b3d74ada1 +Revises: 28a427cbd4cf +Create Date: 2018-06-11 22:50:36.828818 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '605b3d74ada1' +down_revision = '28a427cbd4cf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('thread', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('package_id', sa.Integer(), nullable=True), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('private', sa.Boolean(), server_default='0', nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['package_id'], ['package.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('thread_reply', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('thread_id', sa.Integer(), nullable=False), + sa.Column('comment', sa.String(length=500), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + op.add_column('package', sa.Column('review_thread_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'package', 'thread', ['review_thread_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'package', type_='foreignkey') + op.drop_constraint(None, 'package', type_='foreignkey') + op.drop_column('package', 'review_thread_id') + op.drop_table('thread_reply') + op.drop_table('thread') + # ### end Alembic commands ###