rDrama/files/helpers/comments.py

235 lines
7.5 KiB
Python

from sys import stdout
from typing import Optional
import gevent
from flask import g, request
from pusher_push_notifications import PushNotifications
from sqlalchemy import select, update
from sqlalchemy.orm import Query, aliased
from sqlalchemy.sql.expression import alias, func, text
from files.classes import Comment, Notification, Subscription, User
from files.helpers.alerts import NOTIFY_USERS
from files.helpers.assetcache import assetcache_path
from files.helpers.config.environment import (PUSHER_ID, PUSHER_KEY, SITE_FULL,
SITE_ID)
if PUSHER_ID != 'blahblahblah':
beams_client = PushNotifications(instance_id=PUSHER_ID, secret_key=PUSHER_KEY)
def pusher_thread(interests, c, username):
if len(c.body) > 500: notifbody = c.body[:500] + '...'
else: notifbody = c.body
beams_client.publish_to_interests(
interests=[interests],
publish_body={
'web': {
'notification': {
'title': f'New reply by @{username}',
'body': notifbody,
'deep_link': f'{SITE_FULL}/comment/{c.id}?context=8&read=true#context',
'icon': SITE_FULL + assetcache_path(f'images/{SITE_ID}/icon.webp'),
}
},
'fcm': {
'notification': {
'title': f'New reply by @{username}',
'body': notifbody,
},
'data': {
'url': f'/comment/{c.id}?context=8&read=true#context',
}
}
},
)
stdout.flush()
def update_stateful_counters(comment, delta):
"""
When a comment changes publish status, we need to update all affected stateful
comment counters (e.g. author comment count, post comment count)
"""
update_post_comment_count(comment, delta)
update_author_comment_count(comment, delta)
update_ancestor_descendant_counts(comment, delta)
def update_post_comment_count(comment, delta):
author = comment.author
comment.post.comment_count += delta
g.db.add(comment.post)
def update_author_comment_count(comment, delta):
author = comment.author
comment.author.comment_count = g.db.query(Comment).filter(
Comment.author_id == comment.author_id,
Comment.parent_submission != None,
Comment.is_banned == False,
Comment.state_user_deleted_utc == None,
).count()
g.db.add(comment.author)
def update_ancestor_descendant_counts(comment, delta):
parent = comment.parent_comment_writable
if parent is None:
return
parent.descendant_count += delta
g.db.add(parent)
update_ancestor_descendant_counts(parent, delta)
def bulk_recompute_descendant_counts(predicate = None, db=None):
"""
Recomputes the descendant_count of a large number of comments.
The descendant_count of a comment is equal to the number of direct visible child comments
plus the sum of the descendant_count of those visible child comments.
:param predicate: If set, only update comments matching this predicate
:param db: If set, use this instead of g.db
So for example
>>> bulk_update_descendant_counts()
will update all comments, while
>>> bulk_update_descendant_counts(lambda q: q.where(Comment.parent_submission == 32)
will only update the descendant counts of comments where parent_submission=32
Internally, how this works is
1. Find the maximum level of comments matching the predicate
2. Starting from that level and going down, for each level update the descendant_counts
Since the comments at the max level will always have 0 children, this means that we will perform
`level` updates to update all comments.
The update query looks like
UPDATE comments
SET descendant_count=descendant_counts.descendant_count
FROM (
SELECT
parent_comments.id AS id,
coalesce(sum(child_comments.descendant_count + 1), 0) AS descendant_count
FROM comments AS parent_comments
LEFT OUTER JOIN comments AS child_comments ON parent_comments.id = child_comments.parent_comment_id
GROUP BY parent_comments.id
) AS descendant_counts
WHERE comments.id = descendant_counts.id
AND comments.level = :level_1
<predicate goes here>
"""
db = db if db is not None else g.db
max_level_query = db.query(func.max(Comment.level))
if predicate:
max_level_query = predicate(max_level_query)
max_level = max_level_query.scalar()
if max_level is None:
max_level = 0
for level in range(max_level, 0, -1):
parent_comments = alias(Comment, name="parent_comments")
child_comments = alias(Comment, name="child_comments")
descendant_counts = aliased(
Comment,
(
select(parent_comments)
.join(
child_comments,
parent_comments.corresponding_column(Comment.id) == child_comments.corresponding_column(Comment.parent_comment_id),
True
)
.group_by(parent_comments.corresponding_column(Comment.id))
.with_only_columns(
parent_comments.corresponding_column(Comment.id),
func.coalesce(
func.sum(child_comments.corresponding_column(Comment.descendant_count) + text(str(1))),
text(str(0))
).label('descendant_count')
)
.subquery(name='descendant_counts')
),
adapt_on_names=True
)
update_statement = (
update(Comment)
.values(descendant_count=descendant_counts.descendant_count)
.execution_options(synchronize_session=False)
.where(Comment.id == descendant_counts.id)
.where(Comment.level == level)
)
if predicate:
update_statement = predicate(update_statement)
db.execute(update_statement)
db.commit()
def comment_on_publish(comment:Comment):
"""
Run when comment becomes visible: immediately for non-filtered comments,
or on approval for previously filtered comments.
Should be used to update stateful counters, notifications, etc. that
reflect the comments users will actually see.
"""
author = comment.author
# Shadowbanned users are invisible. This may lead to inconsistencies if
# a user comments while shadowed and is later unshadowed. (TODO?)
if author.shadowbanned:
return
# Comment instances used for purposes other than actual comments (notifs,
# DMs) shouldn't be considered published.
if not comment.parent_submission:
return
# Generate notifs for: mentions, post subscribers, parent post/comment
to_notify = NOTIFY_USERS(comment.body, comment.author)
post_subscribers = g.db.query(Subscription.user_id).filter(
Subscription.submission_id == comment.parent_submission,
Subscription.user_id != comment.author_id,
).all()
to_notify.update([x[0] for x in post_subscribers])
parent = comment.parent
if parent and parent.author_id != comment.author_id and not parent.author.is_blocking(author):
to_notify.add(parent.author_id)
for uid in to_notify:
notif = Notification(comment_id=comment.id, user_id=uid)
g.db.add(notif)
update_stateful_counters(comment, +1)
# Generate push notifications if enabled.
if PUSHER_ID != 'blahblahblah' and comment.author_id != parent.author_id:
try:
gevent.spawn(pusher_thread, f'{request.host}{parent.author.id}',
comment, comment.author_name)
except: pass
def comment_on_unpublish(comment:Comment):
"""
Run when a comment becomes invisible: when a moderator makes the comment non-visible
by changing the filter_state to "removed", or when the user deletes the comment.
Should be used to update stateful counters, notifications, etc. that
reflect the comments users will actually see.
"""
update_stateful_counters(comment, -1)
def comment_filter_moderated(q: Query, v: Optional[User]) -> Query:
if not (v and v.shadowbanned) and not (v and v.admin_level >= 3):
q = q.join(User, User.id == Comment.author_id) \
.filter(User.shadowbanned == None)
if not v or v.admin_level < 2:
q = q.filter(
((Comment.filter_state != 'filtered')
& (Comment.filter_state != 'removed'))
| (Comment.author_id == ((v and v.id) or 0))
)
return q