mirror of
https://github.com/minetest/contentdb.git
synced 2025-01-03 03:37:28 +01:00
Add comment system
This commit is contained in:
parent
40aac38d43
commit
b1c349cc35
@ -76,6 +76,7 @@ class Permission(enum.Enum):
|
|||||||
CHANGE_RANK = "CHANGE_RANK"
|
CHANGE_RANK = "CHANGE_RANK"
|
||||||
CHANGE_EMAIL = "CHANGE_EMAIL"
|
CHANGE_EMAIL = "CHANGE_EMAIL"
|
||||||
EDIT_EDITREQUEST = "EDIT_EDITREQUEST"
|
EDIT_EDITREQUEST = "EDIT_EDITREQUEST"
|
||||||
|
SEE_THREAD = "SEE_THREAD"
|
||||||
|
|
||||||
# Only return true if the permission is valid for *all* contexts
|
# Only return true if the permission is valid for *all* contexts
|
||||||
# See Package.checkPerm for package-specific 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")
|
# causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic")
|
||||||
packages = db.relationship("Package", backref="author", lazy="dynamic")
|
packages = db.relationship("Package", backref="author", lazy="dynamic")
|
||||||
requests = db.relationship("EditRequest", 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):
|
def __init__(self, username, active=False, email=None, password=None):
|
||||||
import datetime
|
import datetime
|
||||||
@ -337,6 +340,9 @@ class Package(db.Model):
|
|||||||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
soft_deleted = 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
|
# Downloads
|
||||||
repo = db.Column(db.String(200), nullable=True)
|
repo = db.Column(db.String(200), nullable=True)
|
||||||
website = 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)
|
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", \
|
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \
|
||||||
"minetest.net", "dropboxusercontent.com", "4shared.com", \
|
"minetest.net", "dropboxusercontent.com", "4shared.com", \
|
||||||
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", \
|
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", \
|
||||||
|
27
app/templates/macros/threads.html
Normal file
27
app/templates/macros/threads.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% macro render_thread(thread, current_user) -%}
|
||||||
|
<ul>
|
||||||
|
{% for r in thread.replies %}
|
||||||
|
<li>
|
||||||
|
<<a href="{{ url_for('user_profile_page', username=r.author.username) }}">{{ r.author.display_name }}</a>>
|
||||||
|
{{ r.comment }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<form method="post" action="{{ url_for('thread_page', id=thread.id)}}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<textarea required maxlength=500 name="comment"></textarea><br />
|
||||||
|
<input type="submit" value="Comment" />
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_threadlist(threads) -%}
|
||||||
|
<ul>
|
||||||
|
{% for t in threads %}
|
||||||
|
<li><a href="{{ url_for('thread_page', id=t.id) }}">{{ t.title }}</a> by {{ t.author.display_name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% endmacro %}
|
@ -43,6 +43,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="clear: both;"></div>
|
<div style="clear: both;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
<div class="box box_grey alert alert-info">
|
||||||
|
Privately ask a question or give feedback
|
||||||
|
|
||||||
|
<a class="alert_right button" href="{{ url_for('new_thread_page', pid=package.id, title='Package approval comments') }}">Open Thread</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h1>{{ package.title }} by {{ package.author.display_name }}</h1>
|
<h1>{{ package.title }} by {{ package.author.display_name }}</h1>
|
||||||
|
12
app/templates/threads/list.html
Normal file
12
app/templates/threads/list.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Threads
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Threads</h1>
|
||||||
|
|
||||||
|
{% from "macros/threads.html" import render_threadlist %}
|
||||||
|
{{ render_threadlist(threads) }}
|
||||||
|
{% endblock %}
|
19
app/templates/threads/new.html
Normal file
19
app/templates/threads/new.html
Normal file
@ -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 method="POST" action="" enctype="multipart/form-data">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
{{ render_field(form.title) }}
|
||||||
|
{{ render_field(form.comment) }}
|
||||||
|
{{ render_field(form.private) }}
|
||||||
|
{{ render_submit_field(form.submit) }}
|
||||||
|
|
||||||
|
<p>Only the you, the package author, and users of Editor rank and above can read private threads.</p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
25
app/templates/threads/view.html
Normal file
25
app/templates/threads/view.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Threads
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if thread.private %}🔒 {% endif %}{{ thread.title }}</h1>
|
||||||
|
|
||||||
|
{% if thread.package %}
|
||||||
|
<p>
|
||||||
|
Package: <a href="{{ thread.package.getDetailsURL() }}">{{ thread.package.title }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if thread.private %}
|
||||||
|
<i>
|
||||||
|
This thread is only visible to its creator, the package owner, and users of
|
||||||
|
Editor rank or above.
|
||||||
|
</i>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% from "macros/threads.html" import render_thread %}
|
||||||
|
{{ render_thread(thread, current_user) }}
|
||||||
|
{% endblock %}
|
@ -51,7 +51,7 @@ def home_page():
|
|||||||
packages = query.order_by(db.desc(Package.created_at)).limit(15).all()
|
packages = query.order_by(db.desc(Package.created_at)).limit(15).all()
|
||||||
return render_template("index.html", packages=packages, count=count)
|
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' })
|
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { 'path': 'help' })
|
||||||
@app.route('/<path:path>/')
|
@app.route('/<path:path>/')
|
||||||
|
@ -110,9 +110,15 @@ def package_page(package):
|
|||||||
|
|
||||||
releases = getReleases(package)
|
releases = getReleases(package)
|
||||||
requests = [r for r in package.requests if r.status == 0]
|
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", \
|
return render_template("packages/view.html", \
|
||||||
package=package, releases=releases, requests=requests, \
|
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/<author>/<name>/download/")
|
@app.route("/packages/<author>/<name>/download/")
|
||||||
|
136
app/views/threads.py
Normal file
136
app/views/threads.py
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
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/<int:id>/", 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)
|
55
migrations/versions/605b3d74ada1_.py
Normal file
55
migrations/versions/605b3d74ada1_.py
Normal file
@ -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 ###
|
Loading…
Reference in New Issue
Block a user