rDrama/files/helpers/comments.py
2023-07-01 21:06:40 -05:00

238 lines
7.6 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.classes.visstate import StateMod
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.state_mod == StateMod.VISIBLE,
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 = g.db.query(Notification) \
.filter_by(comment_id=comment.id, user_id=uid).one_or_none()
if not notif:
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 state_mod 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.state_mod == StateMod.VISIBLE)
| (Comment.author_id == ((v and v.id) or 0))
)
return q