...
 
Commits (11)
{% 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
......@@ -6,6 +6,18 @@
- group: root
- mode: 644
/etc/systemd/system/consumer-comment_user_mentions_generator.service:
file.managed:
- source: salt://consumers/comment_user_mentions_generator.service.jinja2
- template: jinja
- user: root
- group: root
- mode: 644
consumer-topic_metadata_generator.service:
service.running:
- enable: True
consumer-comment_user_mentions_generator.service:
service.running:
- enable: True
"""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')
"""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()
......@@ -161,6 +161,10 @@ ol {
margin-top: 0.2rem;
max-width: $paragraph-max-width - 2rem;
}
&:last-child {
margin-bottom: 0.2rem;
}
}
p {
......
......@@ -3,14 +3,22 @@
// Note that all rules inside the mixin will be included in the compiled CSS
// once for each theme, so they should be kept as minimal as possible.
@mixin commenttag($color, $is-light) {
@mixin specialtag($color, $is-light) {
@if $is-light {
background-color: $color;
a {
color: white;
}
}
@else {
background-color: transparent;
color: $color;
border: 1px solid $color;
a {
color: $color;
}
}
}
......@@ -101,11 +109,11 @@
}
.comment-tags {
.label-comment-tag-joke { @include commenttag($comment-tag-joke-color, $is-light); }
.label-comment-tag-noise { @include commenttag($comment-tag-noise-color, $is-light); }
.label-comment-tag-offtopic { @include commenttag($comment-tag-offtopic-color, $is-light); }
.label-comment-tag-troll { @include commenttag($comment-tag-troll-color, $is-light); }
.label-comment-tag-flame { @include commenttag($comment-tag-flame-color, $is-light); }
.label-comment-tag-joke { @include specialtag($comment-tag-joke-color, $is-light); }
.label-comment-tag-noise { @include specialtag($comment-tag-noise-color, $is-light); }
.label-comment-tag-offtopic { @include specialtag($comment-tag-offtopic-color, $is-light); }
.label-comment-tag-troll { @include specialtag($comment-tag-troll-color, $is-light); }
.label-comment-tag-flame { @include specialtag($comment-tag-flame-color, $is-light); }
}
.is-comment-collapsed {
......@@ -176,6 +184,14 @@
}
}
.label-topic-tag-nsfw {
@include specialtag($topic-tag-nsfw-color, $is-light);
}
.label-topic-tag-spoiler {
@include specialtag($topic-tag-spoiler-color, $is-light);
}
.post-button {
color: $text-secondary-color;
......
......@@ -31,6 +31,10 @@ $fg-dark: $base00;
$fg-light: $base0;
$fg-lightest: $base1;
// Colors for special topic tags
$topic-tag-nsfw-color: $red;
$topic-tag-spoiler-color: $yellow;
// Colors for comment tags
$comment-tag-joke-color: $cyan;
$comment-tag-noise-color: $yellow;
......
......@@ -14,3 +14,8 @@
margin: 0 0.4rem 0 0;
white-space: nowrap;
}
.label-topic-tag-nsfw,
.label-topic-tag-spoiler {
font-weight: bold;
}
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');
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
......@@ -137,10 +137,11 @@ def current_listing_base_url(
The `query` argument allows adding query variables to the generated url.
"""
if request.matched_route.name not in ('home', 'group'):
if request.matched_route.name not in ('home', 'group', 'user'):
raise AttributeError('Current route is not supported.')
base_view_vars = ('order', 'period', 'per_page', 'tag', 'unfiltered')
base_view_vars = (
'order', 'period', 'per_page', 'tag', 'type', 'unfiltered')
query_vars = {
key: val for key, val in request.GET.copy().items()
if key in base_view_vars
......@@ -165,7 +166,7 @@ def current_listing_normal_url(
The `query` argument allows adding query variables to the generated url.
"""
if request.matched_route.name not in ('home', 'group'):
if request.matched_route.name not in ('home', 'group', 'user'):
raise AttributeError('Current route is not supported.')
normal_view_vars = ('order', 'period', 'per_page')
......
......@@ -8,6 +8,7 @@ class CommentNotificationType(enum.Enum):
COMMENT_REPLY = enum.auto()
TOPIC_REPLY = enum.auto()
USER_MENTION = enum.auto()
class CommentSortOption(enum.Enum):
......
......@@ -50,6 +50,8 @@ class Comment(DatabaseModel):
- Setting is_deleted to true will decrement num_comments on all
topic_visit rows for the relevant topic, where the visit_time was
after the time the comment was originally posted.
- Inserting a row or updating markdown will send a rabbitmq message
for "comment.created" or "comment.edited" respectively.
Internal:
- deleted_time will be set when is_deleted is set to true
"""
......@@ -95,6 +97,8 @@ class Comment(DatabaseModel):
user: User = relationship('User', lazy=False, innerjoin=True)
topic: Topic = relationship('Topic', innerjoin=True)
parent_comment: Optional['Comment'] = relationship(
'Comment', uselist=False, remote_side=[comment_id])
@hybrid_property
def markdown(self) -> str:
......
"""Contains the CommentNotification class."""
from datetime import datetime
import re
from typing import List, Tuple
from sqlalchemy import Boolean, Column, ForeignKey, Integer, TIMESTAMP
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.orm import relationship, Session
from sqlalchemy.sql.expression import text
from tildes.enums import CommentNotificationType
from tildes.lib.markdown import LinkifyFilter
from tildes.models import DatabaseModel
from tildes.models.user import User
from .comment import Comment
......@@ -72,3 +75,97 @@ class CommentNotification(DatabaseModel):
def is_topic_reply(self) -> bool:
"""Return whether this is a topic reply notification."""
return self.notification_type == CommentNotificationType.TOPIC_REPLY
@property
def is_mention(self) -> bool:
"""Return whether this is a mention notification."""
return self.notification_type == CommentNotificationType.USER_MENTION
@classmethod
def get_mentions_for_comment(
cls,
db_session: Session,
comment: Comment,
) -> List['CommentNotification']:
"""Get a list of notifications for user mentions in the comment."""
notifications = []
raw_names = re.findall(
LinkifyFilter.USERNAME_REFERENCE_REGEX,
comment.markdown,
)
users_to_mention = (
db_session.query(User)
.filter(User.username.in_(raw_names)) # type: ignore
.all()
)
parent_comment = comment.parent_comment
for user in users_to_mention:
# prevent the user from mentioning themselves
if comment.user_id == user.user_id:
continue
if parent_comment:
# prevent comment replies from mentioning that comment's poster
if parent_comment.user_id == user.user_id:
continue
# prevent top-level comments from mentioning the thread creator
elif comment.topic.user_id == user.user_id:
continue
mention_notification = cls(
user, comment, CommentNotificationType.USER_MENTION)
notifications.append(mention_notification)
return notifications
@staticmethod
def prevent_duplicate_notifications(
db_session: Session,
comment: Comment,
new_notifications: List['CommentNotification'],
) -> Tuple[List['CommentNotification'], List['CommentNotification']]:
"""Filter new notifications for edited comments.
Protect against sending a notification for the same comment to
the same user twice. Edits can sent notifications to users
now mentioned in the content, but only if they weren't sent
a notification for that comment before.
This method returns a tuple of lists of this class. The first
item is the notifications that were previously sent for this
comment but need to be deleted (i.e. mentioned username was edited
out of the comment), and the second item is the notifications
that need to be added, as they're new.
"""
previous_notifications = (
db_session
.query(CommentNotification)
.filter(
CommentNotification.comment_id == comment.comment_id,
CommentNotification.notification_type ==
CommentNotificationType.USER_MENTION,
).all()
)
new_mention_user_ids = [
notification.user.user_id for notification in new_notifications
]
previous_mention_user_ids = [
notification.user_id for notification in previous_notifications
]
to_delete = [
notification for notification in previous_notifications
if notification.user.user_id not in new_mention_user_ids
]
to_add = [
notification for notification in new_notifications
if notification.user.user_id not in previous_mention_user_ids
]
return (to_delete, to_add)
......@@ -4,12 +4,12 @@ from typing import Any
from pyramid.request import Request
from tildes.models import ModelQuery
from tildes.models.pagination import PaginatedQuery
from .comment import Comment
from .comment_vote import CommentVote
class CommentQuery(ModelQuery):
class CommentQuery(PaginatedQuery):
"""Specialized ModelQuery for Comments."""
def __init__(self, request: Request) -> None:
......
......@@ -3,9 +3,9 @@
from typing import Any, Iterator, List, Optional, TypeVar
from pyramid.request import Request
from sqlalchemy import Column, func
from sqlalchemy import Column, func, inspect
from tildes.lib.id import id36_to_id
from tildes.lib.id import id_to_id36, id36_to_id
from .model_query import ModelQuery
......@@ -22,7 +22,8 @@ class PaginatedQuery(ModelQuery):
super().__init__(model_cls, request)
self._sort_column: Optional[Column] = None
# default to sorting by created_time descending (newest first)
self._sort_column = model_cls.created_time
self.sort_desc = True
self.after_id: Optional[int] = None
......@@ -135,17 +136,16 @@ class PaginatedQuery(ModelQuery):
"""Finalize the query before execution."""
query = super()._finalize()
if self._sort_column:
# if the query is reversed, we need to sort in the opposite dir
# (basically self.sort_desc XOR self.is_reversed)
desc = self.sort_desc
if self.is_reversed:
desc = not desc
# if the query is reversed, we need to sort in the opposite dir
# (basically self.sort_desc XOR self.is_reversed)
desc = self.sort_desc
if self.is_reversed:
desc = not desc
if desc:
query = query.order_by(*self.sorting_columns_desc)
else:
query = query.order_by(*self.sorting_columns)
if desc:
query = query.order_by(*self.sorting_columns_desc)
else:
query = query.order_by(*self.sorting_columns)
# pylint: disable=protected-access
query = query._apply_before_or_after()
......@@ -204,3 +204,21 @@ class PaginatedResults:
def __len__(self) -> int:
"""Return the number of results."""
return len(self.results)
@property
def next_page_after_id36(self) -> str:
"""Return "after" ID36 that should be used to fetch the next page."""
if not self.has_next_page:
raise AttributeError
next_id = inspect(self.results[-1]).identity[0]
return id_to_id36(next_id)
@property
def prev_page_before_id36(self) -> str:
"""Return "before" ID36 that should be used to fetch the prev page."""
if not self.has_prev_page:
raise AttributeError
prev_id = inspect(self.results[0]).identity[0]
return id_to_id36(prev_id)
......@@ -40,6 +40,9 @@ from tildes.schemas.topic import (
# edits inside this period after creation will not mark the topic as edited
EDIT_GRACE_PERIOD = timedelta(minutes=5)
# special tags to put at the front of the tag list
SPECIAL_TAGS = ['nsfw', 'spoiler']
class Topic(DatabaseModel):
"""Model for a topic on the site.
......@@ -153,7 +156,16 @@ class Topic(DatabaseModel):
@hybrid_property
def tags(self) -> List[str]:
"""Return the topic's tags."""
return [str(tag).replace('_', ' ') for tag in self._tags]
sorted_tags = [str(tag).replace('_', ' ') for tag in self._tags]
# move special tags in front
# reverse so that tags at the start of the list appear first
for tag in reversed(SPECIAL_TAGS):
if tag in sorted_tags:
sorted_tags.insert(
0, sorted_tags.pop(sorted_tags.index(tag)))
return sorted_tags
@tags.setter # type: ignore
def tags(self, new_tags: List[str]) -> None:
......@@ -298,6 +310,11 @@ class Topic(DatabaseModel):
return (self.get_content_metadata('domain')
or get_domain_from_url(self.link))
@property
def is_spoiler(self) -> bool:
"""Return whether the topic is marked as a spoiler."""
return 'spoiler' in self.tags
def get_content_metadata(self, key: str) -> Any:
"""Get a piece of content metadata "safely".
......
<ul class="topic-tags">
{% for tag in topic.tags %}
<li class="label label-topic-tag">
<li class="label label-topic-tag label-topic-tag-{{ tag }}">
<a href="/~{{ topic.group.path }}?tag={{ tag.replace(' ', '_') }}">{{ tag }}</a>
</li>
{% else %}
......
......@@ -34,7 +34,7 @@
{% if topic.tags %}
<ul class="topic-tags">
{% for tag in topic.tags %}
<li class="label label-topic-tag">
<li class="label label-topic-tag label-topic-tag-{{ tag }}">
{% if request.matched_route.name in ('home', 'group') %}
<a href="{{ request.current_listing_normal_url({'tag': tag.replace(' ', '_')}) }}">{{ tag }}</a>
{% else %}
......@@ -47,21 +47,7 @@
</div>
{% if topic.is_text_type and topic.get_content_metadata('excerpt') %}
{# if the "excerpt" is the full text, don't wrap in <details> #}
{% if not topic.get_content_metadata('excerpt').endswith('...') %}
<p class="topic-text-excerpt">{{ topic.get_content_metadata('excerpt') }}</p>
{% else %}
<details class="topic-text-excerpt"
{% if request.user and request.user.open_new_tab_text %}
data-js-external-links-new-tabs
{% endif %}
>
<summary>
<span>{{ topic.get_content_metadata('excerpt') }}</span>
</summary>
{{ topic.rendered_html|safe }}
</details>
{% endif %}
{{ topic_excerpt_expandable(topic) }}
{% endif %}
<footer class="topic-info">
......@@ -94,6 +80,31 @@
</article>
{% endmacro %}
{% macro topic_excerpt_expandable(topic) %}
{% if topic.is_spoiler %}
{% set excerpt = 'Warning: this post may contain spoilers' %}
{% set is_expandable = True %}
{% else %}
{% set excerpt = topic.get_content_metadata('excerpt') %}
{# if the excerpt is the full text, it doesn't need to be expandable #}
{% set is_expandable = excerpt.endswith('...') %}
{% endif %}
{% if is_expandable %}
<details class="topic-text-excerpt"
{% if request.user and request.user.open_new_tab_text %}
data-js-external-links-new-tabs
{% endif %}
>
<summary><span>{{ excerpt }}</span></summary>
{{ topic.rendered_html|safe }}
</details>
{% else %}
<p class="topic-text-excerpt">{{ topic.get_content_metadata('excerpt') }}</p>
{% endif %}
{% endmacro %}
{% macro topic_voting(topic) %}
{% if request.has_permission('vote', topic) %}
{% if topic.user_voted %}
......
......@@ -16,9 +16,9 @@
{% if request.user.num_unread_notifications > 0 %}
<a class="logged-in-user-alert" href="/notifications/unread">
{% trans num_notifications=request.user.num_unread_notifications %}
{{ num_notifications }} new reply
{{ num_notifications }} new comment
{% pluralize %}
{{ num_notifications }} new replies
{{ num_notifications }} new comments
{% endtrans %}
</a>
{% endif %}
......
......@@ -7,7 +7,7 @@
<ul class="nav">
<li class="nav-item {{ 'active' if route == 'user' else ''}}">
<a href="/user/{{ request.user }}">
Recent activity
Your posts
</a>
</li>
</ul>
......
{% extends 'base_user_menu.jinja2' %}
{% from 'macros/comments.jinja2' import comment_tag_options_template, render_single_comment with context %}
{% from 'macros/links.jinja2' import group_linked %}
{% from 'macros/links.jinja2' import group_linked, username_linked %}
{% block title %}Unread notifications{% endblock %}
......@@ -16,6 +16,10 @@
<h2>Reply to your comment on <a href="{{ notification.comment.topic.permalink }}">{{ notification.comment.topic.title }}</a> in {{ group_linked(notification.comment.topic.group.path) }}</h2>
{% elif notification.is_topic_reply %}
<h2>Reply to your topic <a href="{{ notification.comment.topic.permalink }}">{{ notification.comment.topic.title }}</a> in {{ group_linked(notification.comment.topic.group.path) }}</h2>
{% elif notification.is_mention %}
<h2>
You were mentioned in a comment on <a href="{{ notification.comment.topic.permalink }}">{{ notification.comment.topic.title }}</a> in {{ group_linked(notification.comment.topic.group.path) }}
</h2>
{% endif %}
{% if notification.is_unread and not request.user.auto_mark_notifications_read %}
......
......@@ -154,13 +154,13 @@
<div class="pagination">
{% if topics.has_prev_page %}
<a class="page-item btn" id="prev-page"
href="{{ request.current_listing_base_url({'before': topics[0].topic_id36}) }}"
href="{{ request.current_listing_base_url({'before': topics.prev_page_before_id36}) }}"
>Prev</a>
{% endif %}
{% if topics.has_next_page %}
<a class="page-item btn" id="next-page"
href="{{ request.current_listing_base_url({'after': topics[-1].topic_id36}) }}"
href="{{ request.current_listing_base_url({'after': topics.next_page_after_id36}) }}"
>Next</a>
{% endif %}
</div>
......
......@@ -10,13 +10,33 @@
<a class="site-header-context" href="/user/{{ user.username }}">{{ user.username }}</a>
{% endblock %}
{% block main_heading %}{{ user.username }}'s recent activity{% endblock %}
{# Only show the heading if they can't see the type tabs #}
{% block main_heading %}
{% if not request.has_permission('view_types', user) %}
{{ user.username }}'s recent activity
{% endif %}
{% endblock %}
{% block content %}
{% if request.has_permission('view_types', user) %}
<div class="listing-options">
<menu class="tab tab-listing-order">
<li class="tab-item{{' active' if not post_type else ''}}">
<a href="{{ request.current_listing_normal_url() }}">Recent activity</a>
</li>
<li class="tab-item{{' active' if post_type == 'topic' else ''}}">
<a href="{{ request.current_listing_normal_url({'type': 'topic'}) }}">Topics</a>
</li>
<li class="tab-item{{ ' active' if post_type == 'comment' else ''}}">
<a href="{{ request.current_listing_normal_url({'type': 'comment'}) }}">Comments</a>
</li>
</menu>
</div>
{% endif %}
{% if merged_posts %}
{% if posts %}
<ol class="post-listing">
{% for post in merged_posts if request.has_permission('view', post) %}
{% for post in posts if request.has_permission('view', post) %}
<li>
{% if post is topic %}
{{ render_topic_for_listing(post, show_group=True) }}
......@@ -27,6 +47,22 @@
</li>
{% endfor %}
</ol>
{% if post_type and (posts.has_prev_page or posts.has_next_page) %}
<div class="pagination">
{% if posts.has_prev_page %}
<a class="page-item btn" id="prev-page"
href="{{ request.current_listing_base_url({'before': posts.prev_page_before_id36}) }}"
>Prev</a>
{% endif %}
{% if posts.has_next_page %}
<a class="page-item btn" id="next-page"
href="{{ request.current_listing_base_url({'after': posts.next_page_after_id36}) }}"
>Next</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty">
<h2 class="empty-title">This user hasn't made any posts</h2>
......
"""Views related to a specific user."""
from typing import List, Union
from marshmallow.fields import String
from marshmallow.validate import OneOf
from pyramid.request import Request
from pyramid.view import view_config
from sqlalchemy.sql.expression import desc
from webargs.pyramidparser import use_kwargs
from tildes.models.comment import Comment
from tildes.models.topic import Topic
from tildes.models.user import UserInviteCode
from tildes.models.user import User, UserInviteCode
from tildes.schemas.topic_listing import TopicListingSchema
@view_config(route_name='user', renderer='user.jinja2')
def get_user(request: Request) -> dict:
"""Generate the main user info page."""
user = request.context
def _get_user_recent_activity(
request: Request,
user: User,
) -> List[Union[Comment, Topic]]:
page_size = 20
# Since we don't know how many comments or topics will be needed to make
......@@ -54,9 +59,56 @@ def get_user(request: Request) -> dict:
)
merged_posts = merged_posts[:page_size]
return merged_posts
@view_config(route_name='user', renderer='user.jinja2')
@use_kwargs(TopicListingSchema(only=('after', 'before', 'per_page')))
@use_kwargs({
'post_type': String(
load_from='type',
validate=OneOf(('topic', 'comment')),
),
})
def get_user(
request: Request,
after: str,
before: str,
per_page: int,
post_type: str = None,
) -> dict:
"""Generate the main user history page."""
user = request.context
if not request.has_permission('view_types', user):
post_type = None
if post_type:
if post_type == 'topic':
query = request.query(Topic).filter(Topic.user == user)
elif post_type == 'comment':
query = request.query(Comment).filter(Comment.user == user)
if before:
query = query.before_id36(before)
if after:
query = query.after_id36(after)
query = query.join_all_relationships()
# include removed posts if the user's looking at their own page
if user == request.user:
query = query.include_removed()
posts = query.get_page(per_page)
else:
posts = _get_user_recent_activity(request, user)
return {
'user': user,
'merged_posts': merged_posts,
'posts': posts,
'post_type': post_type,
}
......