mirror of https://gitlab.com/tildes/tildes.git
Browse Source
Add notifications for users being mentioned
Add notifications for users being mentioned
This detects mentions of users in comments using the same pattern as the markdown parsing uses to generate user links. Mentioned users are sent a notification, and mentions are added/deleted if needed on comment edits. As part of this, setup was done to generate rabbitmq messages for comment creation and edits, and the mentions are handled by an async consumer of these messages.merge-requests/22/head
Celeo
6 years ago
committed by
Deimos
10 changed files with 428 additions and 2 deletions
-
16salt/salt/consumers/comment_user_mentions_generator.service.jinja2
-
12salt/salt/consumers/init.sls
-
72tildes/alembic/versions/f1ecbf24c212_added_user_tag_type_comment_notification.py
-
41tildes/consumers/comment_user_mentions_generator.py
-
31tildes/sql/init/triggers/comments/rabbitmq.sql
-
149tildes/tests/test_comment_user_mentions.py
-
1tildes/tildes/enums.py
-
3tildes/tildes/models/comment/comment.py
-
99tildes/tildes/models/comment/comment_notification.py
-
6tildes/tildes/templates/notifications_unread.jinja2
@ -0,0 +1,16 @@ |
|||||
|
{% from 'common.jinja2' import app_dir, bin_dir -%} |
||||
|
[Unit] |
||||
|
Description=Comment User Mention Generator (Queue Consumer) |
||||
|
Requires=rabbitmq-server.service |
||||
|
After=rabbitmq-server.service |
||||
|
PartOf=rabbitmq-server.service |
||||
|
|
||||
|
[Service] |
||||
|
WorkingDirectory={{ app_dir }}/consumers |
||||
|
Environment="INI_FILE={{ app_dir }}/{{ pillar['ini_file'] }}" |
||||
|
ExecStart={{ bin_dir }}/python comment_user_mentions_generator.py |
||||
|
Restart=always |
||||
|
RestartSec=5 |
||||
|
|
||||
|
[Install] |
||||
|
WantedBy=multi-user.target |
@ -0,0 +1,72 @@ |
|||||
|
"""Add user mention notifications from comments |
||||
|
|
||||
|
Revision ID: f1ecbf24c212 |
||||
|
Revises: de83b8750123 |
||||
|
Create Date: 2018-07-19 02:32:43.684716 |
||||
|
|
||||
|
""" |
||||
|
from alembic import op |
||||
|
|
||||
|
|
||||
|
# revision identifiers, used by Alembic. |
||||
|
revision = 'f1ecbf24c212' |
||||
|
down_revision = 'de83b8750123' |
||||
|
branch_labels = None |
||||
|
depends_on = None |
||||
|
|
||||
|
|
||||
|
def upgrade(): |
||||
|
# ALTER TYPE doesn't work from inside a transaction, disable it |
||||
|
connection = None |
||||
|
if not op.get_context().as_sql: |
||||
|
connection = op.get_bind() |
||||
|
connection.execution_options(isolation_level='AUTOCOMMIT') |
||||
|
|
||||
|
op.execute( |
||||
|
"ALTER TYPE commentnotificationtype " |
||||
|
"ADD VALUE IF NOT EXISTS 'USER_MENTION'" |
||||
|
) |
||||
|
|
||||
|
# re-activate the transaction for any future migrations |
||||
|
if connection is not None: |
||||
|
connection.execution_options(isolation_level='READ_COMMITTED') |
||||
|
|
||||
|
op.execute(''' |
||||
|
CREATE OR REPLACE FUNCTION send_rabbitmq_message_for_comment() RETURNS TRIGGER AS $$ |
||||
|
DECLARE |
||||
|
affected_row RECORD; |
||||
|
payload TEXT; |
||||
|
BEGIN |
||||
|
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN |
||||
|
affected_row := NEW; |
||||
|
ELSIF (TG_OP = 'DELETE') THEN |
||||
|
affected_row := OLD; |
||||
|
END IF; |
||||
|
|
||||
|
payload := json_build_object('comment_id', affected_row.comment_id, 'event_type', TG_OP); |
||||
|
|
||||
|
PERFORM send_rabbitmq_message('comment.' || TG_ARGV[0], payload); |
||||
|
|
||||
|
RETURN NULL; |
||||
|
END; |
||||
|
$$ LANGUAGE plpgsql; |
||||
|
''') |
||||
|
op.execute(''' |
||||
|
CREATE TRIGGER send_rabbitmq_message_for_comment_insert |
||||
|
AFTER INSERT ON comments |
||||
|
FOR EACH ROW |
||||
|
EXECUTE PROCEDURE send_rabbitmq_message_for_comment('created'); |
||||
|
''') |
||||
|
op.execute(''' |
||||
|
CREATE TRIGGER send_rabbitmq_message_for_comment_edit |
||||
|
AFTER UPDATE ON comments |
||||
|
FOR EACH ROW |
||||
|
WHEN (OLD.markdown IS DISTINCT FROM NEW.markdown) |
||||
|
EXECUTE PROCEDURE send_rabbitmq_message_for_comment('edited'); |
||||
|
''') |
||||
|
|
||||
|
|
||||
|
def downgrade(): |
||||
|
op.execute('DROP TRIGGER send_rabbitmq_message_for_comment_insert ON comments') |
||||
|
op.execute('DROP TRIGGER send_rabbitmq_message_for_comment_edit ON comments') |
||||
|
op.execute('DROP FUNCTION send_rabbitmq_message_for_comment') |
@ -0,0 +1,41 @@ |
|||||
|
"""Consumer that generates user mentions for comments.""" |
||||
|
|
||||
|
from amqpy import Message |
||||
|
|
||||
|
from tildes.lib.amqp import PgsqlQueueConsumer |
||||
|
from tildes.models.comment import Comment, CommentNotification |
||||
|
|
||||
|
|
||||
|
class CommentUserMentionGenerator(PgsqlQueueConsumer): |
||||
|
"""Consumer that generates user mentions for comments.""" |
||||
|
|
||||
|
def run(self, msg: Message) -> None: |
||||
|
"""Process a delivered message.""" |
||||
|
comment = ( |
||||
|
self.db_session.query(Comment) |
||||
|
.filter_by(comment_id=msg.body['comment_id']) |
||||
|
.one() |
||||
|
) |
||||
|
new_mentions = CommentNotification.get_mentions_for_comment( |
||||
|
self.db_session, comment) |
||||
|
|
||||
|
if msg.delivery_info['routing_key'] == 'comment.created': |
||||
|
for user_mention in new_mentions: |
||||
|
self.db_session.add(user_mention) |
||||
|
elif msg.delivery_info['routing_key'] == 'comment.edited': |
||||
|
to_delete, to_add = ( |
||||
|
CommentNotification.prevent_duplicate_notifications( |
||||
|
self.db_session, comment, new_mentions)) |
||||
|
|
||||
|
for user_mention in to_delete: |
||||
|
self.db_session.delete(user_mention) |
||||
|
|
||||
|
for user_mention in to_add: |
||||
|
self.db_session.add(user_mention) |
||||
|
|
||||
|
|
||||
|
if __name__ == '__main__': |
||||
|
CommentUserMentionGenerator( |
||||
|
queue_name='comment_user_mentions_generator.q', |
||||
|
routing_keys=['comment.created', 'comment.edited'], |
||||
|
).consume_queue() |
@ -0,0 +1,31 @@ |
|||||
|
CREATE OR REPLACE FUNCTION send_rabbitmq_message_for_comment() RETURNS TRIGGER AS $$ |
||||
|
DECLARE |
||||
|
affected_row RECORD; |
||||
|
payload TEXT; |
||||
|
BEGIN |
||||
|
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN |
||||
|
affected_row := NEW; |
||||
|
ELSIF (TG_OP = 'DELETE') THEN |
||||
|
affected_row := OLD; |
||||
|
END IF; |
||||
|
|
||||
|
payload := json_build_object('comment_id', affected_row.comment_id, 'event_type', TG_OP); |
||||
|
|
||||
|
PERFORM send_rabbitmq_message('comment.' || TG_ARGV[0], payload); |
||||
|
|
||||
|
RETURN NULL; |
||||
|
END; |
||||
|
$$ LANGUAGE plpgsql; |
||||
|
|
||||
|
|
||||
|
CREATE TRIGGER send_rabbitmq_message_for_comment_insert |
||||
|
AFTER INSERT ON comments |
||||
|
FOR EACH ROW |
||||
|
EXECUTE PROCEDURE send_rabbitmq_message_for_comment('created'); |
||||
|
|
||||
|
|
||||
|
CREATE TRIGGER send_rabbitmq_message_for_comment_edit |
||||
|
AFTER UPDATE ON comments |
||||
|
FOR EACH ROW |
||||
|
WHEN (OLD.markdown IS DISTINCT FROM NEW.markdown) |
||||
|
EXECUTE PROCEDURE send_rabbitmq_message_for_comment('edited'); |
@ -0,0 +1,149 @@ |
|||||
|
from pytest import fixture |
||||
|
|
||||
|
from sqlalchemy import and_ |
||||
|
|
||||
|
from tildes.enums import CommentNotificationType |
||||
|
from tildes.models.comment import ( |
||||
|
Comment, |
||||
|
CommentNotification, |
||||
|
) |
||||
|
from tildes.models.topic import Topic |
||||
|
from tildes.models.user import User |
||||
|
|
||||
|
|
||||
|
@fixture |
||||
|
def topic(db, session_group, session_user): |
||||
|
"""Create a topic in the db, delete it as teardown (including comments).""" |
||||
|
new_topic = Topic.create_text_topic( |
||||
|
session_group, session_user, 'Some title', 'some text') |
||||
|
db.add(new_topic) |
||||
|
db.commit() |
||||
|
|
||||
|
yield new_topic |
||||
|
|
||||
|
db.query(Comment).filter_by(topic_id=new_topic.topic_id).delete() |
||||
|
db.delete(new_topic) |
||||
|
db.commit() |
||||
|
|
||||
|
|
||||
|
@fixture |
||||
|
def comment(topic, session_user): |
||||
|
"""Return an unsaved comment.""" |
||||
|
return Comment(topic, session_user, 'Comment content.') |
||||
|
|
||||
|
|
||||
|
@fixture |
||||
|
def user_list(db): |
||||
|
"""Create several users.""" |
||||
|
users = [] |
||||
|
for name in ['foo', 'bar', 'baz']: |
||||
|
user = User(name, 'password') |
||||
|
users.append(user) |
||||
|
db.add(user) |
||||
|
db.commit() |
||||
|
|
||||
|
yield users |
||||
|
|
||||
|
for user in users: |
||||
|
db.delete(user) |
||||
|
db.commit() |
||||
|
|
||||
|
|
||||
|
def test_get_mentions_for_comment(db, user_list, comment): |
||||
|
"""Test that notifications are generated and returned.""" |
||||
|
comment.markdown = '@foo @bar. @baz!' |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert len(mentions) == 3 |
||||
|
for index, user in enumerate(user_list): |
||||
|
assert mentions[index].user == user |
||||
|
|
||||
|
|
||||
|
def test_mention_filtering_parent_comment( |
||||
|
mocker, db, topic, user_list): |
||||
|
"""Test notification filtering for parent comments.""" |
||||
|
parent_comment = Comment(topic, user_list[0], 'Comment content.') |
||||
|
parent_comment.user_id = user_list[0].user_id |
||||
|
comment = mocker.Mock( |
||||
|
user_id=user_list[1].user_id, |
||||
|
markdown=f'@{user_list[0].username}', |
||||
|
parent_comment=parent_comment, |
||||
|
) |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert not mentions |
||||
|
|
||||
|
|
||||
|
def test_mention_filtering_self_mention(db, user_list, topic): |
||||
|
"""Test notification filtering for self-mentions.""" |
||||
|
comment = Comment(topic, user_list[0], f'@{user_list[0]}') |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert not mentions |
||||
|
|
||||
|
|
||||
|
def test_mention_filtering_top_level(db, user_list, session_group): |
||||
|
"""Test notification filtering for top-level comments.""" |
||||
|
topic = Topic.create_text_topic( |
||||
|
session_group, user_list[0], 'Some title', 'some text') |
||||
|
comment = Comment(topic, user_list[1], f'@{user_list[0].username}') |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert not mentions |
||||
|
|
||||
|
|
||||
|
def test_prevent_duplicate_notifications(db, user_list, topic): |
||||
|
"""Test that notifications are cleaned up for edits. |
||||
|
|
||||
|
Flow: |
||||
|
1. A comment is created by user A that mentions user B. Notifications |
||||
|
are generated, and yield A mentioning B. |
||||
|
2. The comment is edited to mention C and not B. |
||||
|
3. The comment is edited to mention B and C. |
||||
|
4. The comment is deleted. |
||||
|
""" |
||||
|
# 1 |
||||
|
comment = Comment(topic, user_list[0], f'@{user_list[1].username}') |
||||
|
db.add(comment) |
||||
|
db.commit() |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert len(mentions) == 1 |
||||
|
assert mentions[0].user == user_list[1] |
||||
|
db.add_all(mentions) |
||||
|
db.commit() |
||||
|
|
||||
|
# 2 |
||||
|
comment.markdown = f'@{user_list[2].username}' |
||||
|
db.commit() |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert len(mentions) == 1 |
||||
|
to_delete, to_add = CommentNotification.prevent_duplicate_notifications( |
||||
|
db, comment, mentions) |
||||
|
assert len(to_delete) == 1 |
||||
|
assert mentions == to_add |
||||
|
assert to_delete[0].user.username == user_list[1].username |
||||
|
|
||||
|
# 3 |
||||
|
comment.markdown = f'@{user_list[1].username} @{user_list[2].username}' |
||||
|
db.commit() |
||||
|
mentions = CommentNotification.get_mentions_for_comment( |
||||
|
db, comment) |
||||
|
assert len(mentions) == 2 |
||||
|
to_delete, to_add = CommentNotification.prevent_duplicate_notifications( |
||||
|
db, comment, mentions) |
||||
|
assert not to_delete |
||||
|
assert len(to_add) == 1 |
||||
|
|
||||
|
# 4 |
||||
|
comment.is_deleted = True |
||||
|
db.commit() |
||||
|
notifications = ( |
||||
|
db.query(CommentNotification.user_id) |
||||
|
.filter(and_( |
||||
|
CommentNotification.comment_id == comment.comment_id, |
||||
|
CommentNotification.notification_type == |
||||
|
CommentNotificationType.USER_MENTION, |
||||
|
)).all()) |
||||
|
assert not notifications |
Write
Preview
Loading…
Cancel
Save
Reference in new issue