contentdb/app/blueprints/threads/__init__.py

410 lines
14 KiB
Python
Raw Normal View History

2020-07-12 17:34:25 +02:00
# ContentDB
2021-01-30 17:59:42 +01:00
# Copyright (C) 2018-21 rubenwardy
2018-06-11 23:49:25 +02:00
#
# This program is free software: you can redistribute it and/or modify
2021-01-30 17:59:42 +01:00
# it under the terms of the GNU Affero General Public License as published by
2018-06-11 23:49:25 +02:00
# 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
2021-01-30 17:59:42 +01:00
# GNU Affero General Public License for more details.
2018-06-11 23:49:25 +02:00
#
2021-01-30 17:59:42 +01:00
# You should have received a copy of the GNU Affero General Public License
2018-06-11 23:49:25 +02:00
# along with this program. If not, see <https://www.gnu.org/licenses/>.
2023-06-19 20:32:36 +02:00
from flask import Blueprint, request, render_template, abort, flash, redirect, url_for
2022-01-07 22:55:33 +01:00
from flask_babel import gettext, lazy_gettext
from sqlalchemy.orm import selectinload
2022-01-17 16:06:03 +01:00
from app.markdown import get_user_mentions, render_markdown
2021-08-17 22:16:43 +02:00
from app.tasks.webhooktasks import post_discord_webhook
bp = Blueprint("threads", __name__)
from flask_login import current_user, login_required
2023-06-19 20:32:36 +02:00
from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \
NotificationType, ThreadReply
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains, \
normalize_line_endings
2018-06-11 23:49:25 +02:00
from flask_wtf import FlaskForm
2023-06-19 20:32:36 +02:00
from wtforms import StringField, TextAreaField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length
2020-01-21 23:40:51 +01:00
from app.utils import get_int_or_abort
2018-06-11 23:49:25 +02:00
2022-01-03 02:41:50 +01:00
@bp.route("/threads/")
def list_all():
2018-07-13 22:28:08 +02:00
query = Thread.query
if not Permission.SEE_THREAD.check(current_user):
query = query.filter_by(private=False)
2020-01-21 23:40:51 +01:00
2022-04-23 22:17:03 +02:00
package = None
2020-01-21 23:40:51 +01:00
pid = request.args.get("pid")
if pid:
pid = get_int_or_abort(pid)
package = Package.query.get_or_404(pid)
2022-04-23 22:17:03 +02:00
query = query.filter_by(package=package)
2020-01-21 23:40:51 +01:00
2020-12-22 13:22:52 +01:00
query = query.filter_by(review_id=None)
2020-07-10 20:46:14 +02:00
query = query.order_by(db.desc(Thread.created_at))
2020-12-22 13:22:52 +01:00
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
2023-04-23 22:49:53 +02:00
pagination = query.paginate(page=page, per_page=num)
2020-12-22 13:22:52 +01:00
return render_template("threads/list.html", pagination=pagination, threads=pagination.items,
package=package, noindex=pid)
2018-06-11 23:49:25 +02:00
2018-07-28 16:30:59 +02:00
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
2018-07-28 16:30:59 +02:00
@login_required
def subscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
2018-07-28 16:30:59 +02:00
abort(404)
if current_user in thread.watchers:
2022-01-07 22:46:16 +01:00
flash(gettext("Already subscribed!"), "success")
2018-07-28 16:30:59 +02:00
else:
2022-01-07 22:46:16 +01:00
flash(gettext("Subscribed to thread"), "success")
2018-07-28 16:30:59 +02:00
thread.watchers.append(current_user)
db.session.commit()
return redirect(thread.get_view_url())
2018-07-28 16:30:59 +02:00
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
2018-07-28 16:30:59 +02:00
@login_required
def unsubscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
2018-07-28 16:30:59 +02:00
abort(404)
if current_user in thread.watchers:
2022-01-07 22:46:16 +01:00
flash(gettext("Unsubscribed!"), "success")
2018-07-28 16:30:59 +02:00
thread.watchers.remove(current_user)
db.session.commit()
else:
2022-01-07 22:46:16 +01:00
flash(gettext("Already not subscribed!"), "success")
2018-07-28 16:30:59 +02:00
return redirect(thread.get_view_url())
2020-07-11 02:34:51 +02:00
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
@login_required
def set_lock(id):
thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD):
2020-07-11 02:34:51 +02:00
abort(404)
2023-06-19 22:27:49 +02:00
thread.locked = is_yes(request.args.get("lock"))
2020-07-11 02:34:51 +02:00
if thread.locked is None:
abort(400)
if thread.locked:
2020-07-11 03:32:17 +02:00
msg = "Locked thread '{}'".format(thread.title)
2022-01-07 22:46:16 +01:00
flash(gettext("Locked thread"), "success")
2020-07-11 02:34:51 +02:00
else:
2020-07-11 03:32:17 +02:00
msg = "Unlocked thread '{}'".format(thread.title)
2022-01-07 22:46:16 +01:00
flash(gettext("Unlocked thread"), "success")
2020-07-11 02:34:51 +02:00
2023-06-19 22:27:49 +02:00
add_notification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package)
2020-07-11 03:32:17 +02:00
db.session.commit()
return redirect(thread.get_view_url())
2018-07-28 16:30:59 +02:00
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
@login_required
def delete_thread(id):
thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD):
abort(404)
if request.method == "GET":
return render_template("threads/delete_thread.html", thread=thread)
summary = "\n\n".join([("<{}> {}".format(reply.author.display_name, reply.comment)) for reply in thread.replies])
msg = "Deleted thread {} by {}".format(thread.title, thread.author.display_name)
db.session.delete(thread)
2023-06-19 22:27:49 +02:00
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
db.session.commit()
return redirect(url_for("homepage.home"))
@bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"])
@login_required
def delete_reply(id):
thread = Thread.query.get(id)
if thread is None:
abort(404)
reply_id = request.args.get("reply")
if reply_id is None:
abort(404)
reply = ThreadReply.query.get(reply_id)
if reply is None or reply.thread != thread:
abort(404)
if thread.first_reply == reply:
2022-01-07 22:46:16 +01:00
flash(gettext("Cannot delete thread opening post!"), "danger")
return redirect(thread.get_view_url())
if not reply.check_perm(current_user, Permission.DELETE_REPLY):
abort(403)
if request.method == "GET":
return render_template("threads/delete_reply.html", thread=thread, reply=reply)
msg = "Deleted reply by {}".format(reply.author.display_name)
2023-06-19 22:27:49 +02:00
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
db.session.delete(reply)
db.session.commit()
return redirect(thread.get_view_url())
2020-07-11 04:52:56 +02:00
class CommentForm(FlaskForm):
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)], filters=[normalize_line_endings])
btn_submit = SubmitField(lazy_gettext("Comment"))
2020-07-11 04:52:56 +02:00
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@login_required
def edit_reply(id):
thread = Thread.query.get(id)
2020-07-11 04:52:56 +02:00
if thread is None:
abort(404)
reply_id = request.args.get("reply")
if reply_id is None:
abort(404)
2023-01-03 13:17:01 +01:00
reply: ThreadReply = ThreadReply.query.get(reply_id)
2020-07-11 04:52:56 +02:00
if reply is None or reply.thread != thread:
abort(404)
if not reply.check_perm(current_user, Permission.EDIT_REPLY):
2020-07-11 04:52:56 +02:00
abort(403)
form = CommentForm(formdata=request.form, obj=reply)
2020-12-05 00:07:19 +01:00
if form.validate_on_submit():
2020-07-11 04:52:56 +02:00
comment = form.comment.data
2023-01-03 13:17:01 +01:00
if has_blocked_domains(comment, current_user.username, f"edit to reply {reply.get_url(True)}"):
2023-01-12 17:43:08 +01:00
flash(gettext("Linking to blocked sites is not allowed"), "danger")
2023-01-03 13:17:01 +01:00
else:
msg = "Edited reply by {}".format(reply.author.display_name)
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
2023-06-19 22:27:49 +02:00
add_notification(reply.author, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
add_audit_log(severity, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
2020-07-11 04:52:56 +02:00
2023-01-03 13:17:01 +01:00
reply.comment = comment
2020-07-11 04:52:56 +02:00
2023-01-03 13:17:01 +01:00
db.session.commit()
2020-07-11 04:52:56 +02:00
return redirect(thread.get_view_url())
2020-07-11 04:52:56 +02:00
return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form)
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id):
thread: Thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
2018-06-11 23:49:25 +02:00
abort(404)
form = CommentForm(formdata=request.form) if thread.check_perm(current_user, Permission.COMMENT_THREAD) else None
2018-06-11 23:49:25 +02:00
# Check that title is none to load comments into textarea if redirected from new thread page
if form and form.validate_on_submit() and request.form.get("title") is None:
2022-04-23 21:05:19 +02:00
comment = form.comment.data
2020-07-11 02:34:51 +02:00
if not current_user.can_comment_ratelimit():
2022-01-07 22:46:16 +01:00
flash(gettext("Please wait before commenting again"), "danger")
return redirect(thread.get_view_url())
if has_blocked_domains(comment, current_user.username, f"reply to {thread.get_view_url(True)}"):
2023-01-12 17:43:08 +01:00
flash(gettext("Linking to blocked sites is not allowed"), "danger")
2023-01-03 13:17:01 +01:00
return render_template("threads/view.html", thread=thread, form=form)
2022-04-23 21:05:19 +02:00
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
2018-06-12 00:38:03 +02:00
2022-04-23 21:05:19 +02:00
thread.replies.append(reply)
if current_user not in thread.watchers:
thread.watchers.append(current_user)
2022-01-17 16:06:03 +01:00
2022-04-23 21:05:19 +02:00
for mentioned_username in get_user_mentions(render_markdown(comment)):
2022-04-23 22:31:59 +02:00
mentioned = User.query.filter_by(username=mentioned_username).first()
2022-04-23 21:05:19 +02:00
if mentioned is None:
continue
2022-01-17 16:06:03 +01:00
2022-04-23 21:05:19 +02:00
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
2023-06-19 22:27:49 +02:00
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY,
msg, thread.get_view_url(), thread.package)
if mentioned not in thread.watchers:
thread.watchers.append(mentioned)
2022-04-23 21:05:19 +02:00
msg = "New comment on '{}'".format(thread.title)
2023-06-19 22:27:49 +02:00
add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package)
2022-04-23 21:05:19 +02:00
if thread.author == get_system_user():
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
2023-06-19 22:27:49 +02:00
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
thread.get_view_url(), thread.package)
post_discord_webhook.delay(current_user.display_name,
"Replied to bot messages: {}".format(thread.get_view_url(absolute=True)), True)
2018-06-11 23:49:25 +02:00
2022-04-23 21:05:19 +02:00
db.session.commit()
2018-06-11 23:49:25 +02:00
return redirect(thread.get_view_url())
2018-06-11 23:49:25 +02:00
2022-04-23 21:05:19 +02:00
return render_template("threads/view.html", thread=thread, form=form)
2018-06-11 23:49:25 +02:00
class ThreadForm(FlaskForm):
2022-01-07 22:55:33 +01:00
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)], filters=[normalize_line_endings])
2022-01-07 22:55:33 +01:00
private = BooleanField(lazy_gettext("Private"))
btn_submit = SubmitField(lazy_gettext("Open Thread"))
2018-06-11 23:49:25 +02:00
@bp.route("/threads/new/", methods=["GET", "POST"])
2018-06-11 23:49:25 +02:00
@login_required
def new():
2018-06-11 23:49:25 +02:00
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:
2022-04-23 22:17:03 +02:00
abort(404)
2018-06-11 23:49:25 +02:00
2022-04-23 22:17:03 +02:00
def_is_private = request.args.get("private") or False
2023-06-19 22:27:49 +02:00
if package is None and not current_user.rank.at_least(UserRank.APPROVER):
2022-04-23 22:17:03 +02:00
abort(404)
2018-06-11 23:49:25 +02:00
is_review_thread = package and not package.approved
allow_private_change = not is_review_thread
if is_review_thread:
def_is_private = True
2018-06-11 23:49:25 +02:00
# Check that user can make the thread
if package and not package.check_perm(current_user, Permission.CREATE_THREAD):
2022-01-07 22:46:16 +01:00
flash(gettext("Unable to create thread!"), "danger")
2019-11-21 20:38:26 +01:00
return redirect(url_for("homepage.home"))
2018-06-11 23:49:25 +02:00
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
# Redirect submit to `view` page, which checks for `title` in the form data and so won't commit the reply
flash(gettext("An approval thread already exists! Consider replying there instead"), "danger")
return redirect(package.review_thread.get_view_url(), code=307)
elif not current_user.can_open_thread_ratelimit():
2022-01-07 22:46:16 +01:00
flash(gettext("Please wait before opening another thread"), "danger")
if package:
return redirect(package.get_url("packages.view"))
else:
2019-11-21 20:38:26 +01:00
return redirect(url_for("homepage.home"))
2018-06-11 23:49:25 +02:00
# Set default values
elif request.method == "GET":
form.private.data = def_is_private
form.title.data = request.args.get("title") or ""
# Validate and submit
2020-12-05 00:07:19 +01:00
elif form.validate_on_submit():
2023-01-03 13:17:01 +01:00
if has_blocked_domains(form.comment.data, current_user.username, f"new thread"):
2023-01-12 17:43:08 +01:00
flash(gettext("Linking to blocked sites is not allowed"), "danger")
2023-01-03 13:17:01 +01:00
else:
thread = Thread()
thread.author = current_user
thread.title = form.title.data
thread.private = form.private.data if allow_private_change else def_is_private
thread.package = package
db.session.add(thread)
2018-06-11 23:49:25 +02:00
2023-01-03 13:17:01 +01:00
thread.watchers.append(current_user)
if package and package.author != current_user:
thread.watchers.append(package.author)
2018-06-12 00:38:03 +02:00
2023-01-03 13:17:01 +01:00
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
2018-06-11 23:49:25 +02:00
2023-01-03 13:17:01 +01:00
thread.replies.append(reply)
2018-06-11 23:49:25 +02:00
2023-01-03 13:17:01 +01:00
db.session.commit()
2018-06-11 23:49:25 +02:00
2023-01-03 13:17:01 +01:00
if is_review_thread:
package.review_thread = thread
2018-06-11 23:49:25 +02:00
2023-01-03 13:17:01 +01:00
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
mentioned = User.query.filter_by(username=mentioned_username).first()
if mentioned is None:
continue
2022-01-17 16:06:03 +01:00
2023-01-03 13:17:01 +01:00
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
2023-06-19 22:27:49 +02:00
add_notification(mentioned, current_user, NotificationType.NEW_THREAD,
msg, thread.get_view_url(), thread.package)
2022-01-17 16:06:03 +01:00
2024-09-28 20:06:50 +02:00
if mentioned not in thread.watchers:
thread.watchers.append(mentioned)
2022-04-23 22:17:03 +02:00
2023-01-03 13:17:01 +01:00
notif_msg = "New thread '{}'".format(thread.title)
if package is not None:
2023-06-19 22:27:49 +02:00
add_notification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.get_view_url(), package)
2023-01-03 13:17:01 +01:00
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
2023-06-19 22:27:49 +02:00
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.get_view_url(), package)
2018-06-11 23:49:25 +02:00
2023-01-03 13:17:01 +01:00
if is_review_thread:
post_discord_webhook.delay(current_user.display_name,
"Opened approval thread: {}".format(thread.get_view_url(absolute=True)), True)
2021-08-17 22:16:43 +02:00
2023-01-03 13:17:01 +01:00
db.session.commit()
2018-06-11 23:49:25 +02:00
return redirect(thread.get_view_url())
2018-06-11 23:49:25 +02:00
2022-04-23 22:44:27 +02:00
return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
2022-01-03 02:41:50 +01:00
2022-01-03 02:44:12 +01:00
@bp.route("/users/<username>/comments/")
def user_comments(username):
2022-01-03 02:41:50 +01:00
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 40))
# Filter replies the current user can see
query = ThreadReply.query.options(selectinload(ThreadReply.thread)).filter_by(author=user)
2023-09-30 00:10:08 +02:00
only_public = False
if current_user != user and not (current_user.is_authenticated and current_user.rank.at_least(UserRank.APPROVER)):
2023-09-30 00:10:08 +02:00
query = query.filter(ThreadReply.thread.has(private=False))
only_public = True
pagination = query.order_by(db.desc(ThreadReply.created_at)).paginate(page=page, per_page=num)
2023-09-30 00:10:08 +02:00
return render_template("threads/user_comments.html", user=user, pagination=pagination, only_public=only_public)