Browse Source

Add bookmarking for topics and comments

Allows users to "bookmark" topics and comments, and view their list of
bookmarks through their user page.
merge-requests/51/head
Ivan Fonseca 6 years ago
committed by Deimos
parent
commit
244bdda526
  1. 87
      tildes/alembic/versions/53567981cdf4_add_topic_and_comment_bookmark_tables.py
  2. 2
      tildes/tildes/__init__.py
  3. 3
      tildes/tildes/database_models.py
  4. 1
      tildes/tildes/models/comment/__init__.py
  5. 4
      tildes/tildes/models/comment/comment.py
  6. 38
      tildes/tildes/models/comment/comment_bookmark.py
  7. 21
      tildes/tildes/models/comment/comment_query.py
  8. 1
      tildes/tildes/models/topic/__init__.py
  9. 4
      tildes/tildes/models/topic/topic.py
  10. 38
      tildes/tildes/models/topic/topic_bookmark.py
  11. 19
      tildes/tildes/models/topic/topic_query.py
  12. 8
      tildes/tildes/routes.py
  13. 59
      tildes/tildes/templates/bookmarks.jinja2
  14. 23
      tildes/tildes/templates/macros/comments.jinja2
  15. 5
      tildes/tildes/templates/macros/user_menu.jinja2
  16. 24
      tildes/tildes/templates/topic.jinja2
  17. 63
      tildes/tildes/views/api/web/comment.py
  18. 40
      tildes/tildes/views/api/web/topic.py
  19. 69
      tildes/tildes/views/bookmarks.py

87
tildes/alembic/versions/53567981cdf4_add_topic_and_comment_bookmark_tables.py

@ -0,0 +1,87 @@
"""Add topic and comment bookmark tables
Revision ID: 53567981cdf4
Revises: 5a7dc1032efc
Create Date: 2018-08-17 17:57:22.171858
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "53567981cdf4"
down_revision = "5a7dc1032efc"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"topic_bookmarks",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("topic_id", sa.Integer(), nullable=False),
sa.Column(
"created_time",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["topic_id"],
["topics.topic_id"],
name=op.f("fk_topic_bookmarks_topic_id_topics"),
),
sa.ForeignKeyConstraint(
["user_id"],
["users.user_id"],
name=op.f("fk_topic_bookmarks_user_id_users"),
),
sa.PrimaryKeyConstraint("user_id", "topic_id", name=op.f("pk_topic_bookmarks")),
)
op.create_index(
op.f("ix_topic_bookmarks_created_time"),
"topic_bookmarks",
["created_time"],
unique=False,
)
op.create_table(
"comment_bookmarks",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("comment_id", sa.Integer(), nullable=False),
sa.Column(
"created_time",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["comment_id"],
["comments.comment_id"],
name=op.f("fk_comment_bookmarks_comment_id_comments"),
),
sa.ForeignKeyConstraint(
["user_id"],
["users.user_id"],
name=op.f("fk_comment_bookmarks_user_id_users"),
),
sa.PrimaryKeyConstraint(
"user_id", "comment_id", name=op.f("pk_comment_bookmarks")
),
)
op.create_index(
op.f("ix_comment_bookmarks_created_time"),
"comment_bookmarks",
["created_time"],
unique=False,
)
def downgrade():
op.drop_index(
op.f("ix_comment_bookmarks_created_time"), table_name="comment_bookmarks"
)
op.drop_table("comment_bookmarks")
op.drop_index(op.f("ix_topic_bookmarks_created_time"), table_name="topic_bookmarks")
op.drop_table("topic_bookmarks")

2
tildes/tildes/__init__.py

@ -143,6 +143,7 @@ def current_listing_base_url(
The `query` argument allows adding query variables to the generated url.
"""
base_vars_by_route: Dict[str, Tuple[str, ...]] = {
"bookmarks": ("per_page", "type"),
"group": ("order", "period", "per_page", "tag", "unfiltered"),
"home": ("order", "period", "per_page", "tag", "unfiltered"),
"search": ("order", "period", "per_page", "q"),
@ -177,6 +178,7 @@ def current_listing_normal_url(
The `query` argument allows adding query variables to the generated url.
"""
normal_vars_by_route: Dict[str, Tuple[str, ...]] = {
"bookmarks": ("order", "period", "per_page"),
"group": ("order", "period", "per_page"),
"home": ("order", "period", "per_page"),
"notifications": ("per_page",),

3
tildes/tildes/database_models.py

@ -7,6 +7,7 @@ both Alembic and the script for initializing the database can simply import * fr
from tildes.models.comment import (
Comment,
CommentBookmark,
CommentLabel,
CommentNotification,
CommentVote,
@ -15,5 +16,5 @@ from tildes.models.group import Group, GroupSubscription
from tildes.models.log import Log
from tildes.models.message import MessageConversation, MessageReply
from tildes.models.scraper import ScraperResult
from tildes.models.topic import Topic, TopicVisit, TopicVote
from tildes.models.topic import Topic, TopicBookmark, TopicVisit, TopicVote
from tildes.models.user import User, UserGroupSettings, UserInviteCode

1
tildes/tildes/models/comment/__init__.py

@ -1,6 +1,7 @@
"""Contains models related to comments."""
from .comment import Comment, EDIT_GRACE_PERIOD
from .comment_bookmark import CommentBookmark
from .comment_notification import CommentNotification
from .comment_notification_query import CommentNotificationQuery
from .comment_query import CommentQuery

4
tildes/tildes/models/comment/comment.py

@ -203,6 +203,10 @@ class Comment(DatabaseModel):
# - logged-in users can mark comments read
acl.append((Allow, Authenticated, "mark_read"))
# bookmark:
# - logged-in users can bookmark comments
acl.append((Allow, Authenticated, "bookmark"))
# tools that require specifically granted permissions
acl.append((Allow, "admin", "remove"))
acl.append((Allow, "admin", "view_labels"))

38
tildes/tildes/models/comment/comment_bookmark.py

@ -0,0 +1,38 @@
"""Contains the CommentBookmark class."""
from datetime import datetime
from sqlalchemy import Column, ForeignKey, Integer, TIMESTAMP
from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import text
from tildes.models import DatabaseModel
from tildes.models.comment import Comment
from tildes.models.user import User
class CommentBookmark(DatabaseModel):
"""Model for a comment bookmark."""
__tablename__ = "comment_bookmarks"
user_id: int = Column(
Integer, ForeignKey("users.user_id"), nullable=False, primary_key=True
)
comment_id: int = Column(
Integer, ForeignKey("comments.comment_id"), nullable=False, primary_key=True
)
created_time: datetime = Column(
TIMESTAMP(timezone=True),
nullable=False,
index=True,
server_default=text("NOW()"),
)
user: User = relationship("User", innerjoin=True)
comment: Comment = relationship("Comment", innerjoin=True)
def __init__(self, user: User, comment: Comment) -> None:
"""Create a new comment bookmark."""
self.user = user
self.comment = comment

21
tildes/tildes/models/comment/comment_query.py

@ -6,9 +6,11 @@
from typing import Any
from pyramid.request import Request
from sqlalchemy.sql.expression import and_
from tildes.models.pagination import PaginatedQuery
from .comment import Comment
from .comment_bookmark import CommentBookmark
from .comment_vote import CommentVote
@ -25,10 +27,11 @@ class CommentQuery(PaginatedQuery):
def _attach_extra_data(self) -> "CommentQuery":
"""Attach the extra user data to the query."""
# pylint: disable=protected-access
if not self.request.user:
return self
return self._attach_vote_data()
return self._attach_vote_data()._attach_bookmark_data()
def _attach_vote_data(self) -> "CommentQuery":
"""Add a subquery to include whether the user has voted."""
@ -43,6 +46,19 @@ class CommentQuery(PaginatedQuery):
)
return self.add_columns(vote_subquery)
def _attach_bookmark_data(self) -> "CommentQuery":
"""Join the data related to whether the user has bookmarked the comment."""
query = self.outerjoin(
CommentBookmark,
and_(
CommentBookmark.comment_id == Comment.comment_id,
CommentBookmark.user == self.request.user,
),
)
query = query.add_columns(CommentBookmark.created_time)
return query
@staticmethod
def _process_result(result: Any) -> Comment:
"""Merge additional user-context data in result onto the comment."""
@ -50,8 +66,11 @@ class CommentQuery(PaginatedQuery):
# the result is already a Comment, no merging needed
comment = result
comment.user_voted = False
comment.bookmark_created_time = None
else:
comment = result.Comment
comment.user_voted = result.user_voted
comment.bookmark_created_time = result.created_time
return comment

1
tildes/tildes/models/topic/__init__.py

@ -1,6 +1,7 @@
"""Contains models related to topics."""
from .topic import EDIT_GRACE_PERIOD, Topic
from .topic_bookmark import TopicBookmark
from .topic_query import TopicQuery
from .topic_visit import TopicVisit
from .topic_vote import TopicVote

4
tildes/tildes/models/topic/topic.py

@ -297,6 +297,10 @@ class Topic(DatabaseModel):
acl.append((Allow, "admin", "edit_title"))
acl.append((Allow, "topic.edit_title", "edit_title"))
# bookmark:
# - logged-in users can bookmark topics
acl.append((Allow, Authenticated, "bookmark"))
acl.append(DENY_ALL)
return acl

38
tildes/tildes/models/topic/topic_bookmark.py

@ -0,0 +1,38 @@
"""Contains the TopicBookmark class."""
from datetime import datetime
from sqlalchemy import Column, ForeignKey, Integer, TIMESTAMP
from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import text
from tildes.models import DatabaseModel
from tildes.models.topic import Topic
from tildes.models.user import User
class TopicBookmark(DatabaseModel):
"""Model for a topic bookmark."""
__tablename__ = "topic_bookmarks"
user_id: int = Column(
Integer, ForeignKey("users.user_id"), nullable=False, primary_key=True
)
topic_id: int = Column(
Integer, ForeignKey("topics.topic_id"), nullable=False, primary_key=True
)
created_time: datetime = Column(
TIMESTAMP(timezone=True),
nullable=False,
index=True,
server_default=text("NOW()"),
)
user: User = relationship("User", innerjoin=True)
topic: Topic = relationship("Topic", innerjoin=True)
def __init__(self, user: User, topic: Topic) -> None:
"""Create a new topic bookmark."""
self.user = user
self.topic = topic

19
tildes/tildes/models/topic/topic_query.py

@ -15,6 +15,7 @@ from tildes.lib.datetime import SimpleHoursPeriod, utc_now
from tildes.models.group import Group
from tildes.models.pagination import PaginatedQuery
from .topic import Topic
from .topic_bookmark import TopicBookmark
from .topic_visit import TopicVisit
from .topic_vote import TopicVote
@ -38,7 +39,7 @@ class TopicQuery(PaginatedQuery):
return self
# pylint: disable=protected-access
return self._attach_vote_data()._attach_visit_data()
return self._attach_vote_data()._attach_visit_data()._attach_bookmark_data()
def _attach_vote_data(self) -> "TopicQuery":
"""Add a subquery to include whether the user has voted."""
@ -53,6 +54,19 @@ class TopicQuery(PaginatedQuery):
)
return self.add_columns(vote_subquery)
def _attach_bookmark_data(self) -> "TopicQuery":
"""Join the data related to whether the user has bookmarked the topic."""
query = self.outerjoin(
TopicBookmark,
and_(
TopicBookmark.topic_id == Topic.topic_id,
TopicBookmark.user == self.request.user,
),
)
query = query.add_columns(TopicBookmark.created_time)
return query
def _attach_visit_data(self) -> "TopicQuery":
"""Join the data related to the user's last visit to the topic(s)."""
# pylint: disable=assignment-from-no-return
@ -80,6 +94,7 @@ class TopicQuery(PaginatedQuery):
# the result is already a Topic, no merging needed
topic = result
topic.user_voted = False
topic.bookmark_created_time = None
topic.last_visit_time = None
topic.comments_since_last_visit = None
else:
@ -87,6 +102,8 @@ class TopicQuery(PaginatedQuery):
topic.user_voted = result.user_voted
topic.bookmark_created_time = result.created_time
topic.last_visit_time = result.visit_time
if result.num_comments is not None:
new_comments = topic.num_comments - result.num_comments

8
tildes/tildes/routes.py

@ -83,6 +83,8 @@ def includeme(config: Configurator) -> None:
"settings_password_change", "/settings/password_change", factory=LoggedInFactory
)
config.add_route("bookmarks", "/bookmarks", factory=LoggedInFactory)
config.add_route("invite", "/invite", factory=LoggedInFactory)
# Route to expose metrics to Prometheus
@ -122,6 +124,9 @@ def add_intercooler_routes(config: Configurator) -> None:
add_ic_route("topic_title", "/topics/{topic_id36}/title", factory=topic_by_id36)
add_ic_route("topic_vote", "/topics/{topic_id36}/vote", factory=topic_by_id36)
add_ic_route("topic_tags", "/topics/{topic_id36}/tags", factory=topic_by_id36)
add_ic_route(
"topic_bookmark", "/topics/{topic_id36}/bookmark", factory=topic_by_id36
)
add_ic_route("comment", "/comments/{comment_id36}", factory=comment_by_id36)
add_ic_route(
@ -138,6 +143,9 @@ def add_intercooler_routes(config: Configurator) -> None:
"/comments/{comment_id36}/labels/{name}",
factory=comment_by_id36,
)
add_ic_route(
"comment_bookmark", "/comments/{comment_id36}/bookmark", factory=comment_by_id36
)
add_ic_route(
"comment_mark_read",
"/comments/{comment_id36}/mark_read",

59
tildes/tildes/templates/bookmarks.jinja2

@ -0,0 +1,59 @@
{% extends 'base_user_menu.jinja2' %}
{% from 'macros/comments.jinja2' import render_single_comment with context %}
{% from 'macros/links.jinja2' import group_linked, username_linked %}
{% from 'macros/topics.jinja2' import render_topic_for_listing with context %}
{% block title %}Bookmarks{% endblock %}
{% block main_heading %}Bookmarks{% endblock %}
{% block content %}
<div class="listing-options">
<menu class="tab tab-listing-order">
<li class="tab-item{{' active' if (post_type == 'topic' or not post_type) 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>
{% if posts %}
<ol class="post-listing">
{% for post in posts if request.has_permission('view', post) %}
<li>
{% if post is topic %}
{{ render_topic_for_listing(post, show_group=True) }}
{% elif post is comment %}
<h2>Comment on <a href="{{ post.topic.permalink }}">{{ post.topic.title }}</a> in {{ group_linked(post.topic.group.path) }}</h2>
{{ render_single_comment(post) }}
{% endif %}
</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">You haven't bookmarked any posts</h2>
</div>
{% endif %}
{% endblock %}

23
tildes/tildes/templates/macros/comments.jinja2

@ -194,6 +194,29 @@
>Delete</a></li>
{% endif %}
{% if request.has_permission('bookmark', comment) %}
{% if comment.bookmark_created_time %}
<li><a class="post-button post-button-used" name="unbookmark"
data-ic-delete-from="{{ request.route_url(
'ic_comment_bookmark',
comment_id36=comment.comment_id36,
) }}"
data-ic-target="#comment-{{ comment.comment_id36 }} .comment-itself:first"
data-ic-replace-target="true"
>Bookmarked
{% else %}
<li><a class="post-button" name="bookmark"
data-ic-put-to="{{ request.route_url(
'ic_comment_bookmark',
comment_id36=comment.comment_id36,
) }}"
data-ic-target="#comment-{{ comment.comment_id36 }} .comment-itself:first"
data-ic-replace-target="true"
>Bookmark
{% endif %}
</a></li>
{% endif %}
{% if request.has_permission("remove", comment) %}
<li>
{% if not comment.is_removed %}

5
tildes/tildes/templates/macros/user_menu.jinja2

@ -54,6 +54,11 @@
<li>Misc</li>
<ul class="nav">
<li class="nav-item {{ 'active' if route == 'bookmarks' else '' }}">
<a href="/bookmarks">
Bookmarks
</a>
</li>
<li class="nav-item {{ 'active' if route == 'invite' else '' }}">
{% if request.user.invite_codes_remaining %}<strong>{% endif %}
<a href="/invite">

24
tildes/tildes/templates/topic.jinja2

@ -63,7 +63,7 @@
{% endif %}
{% endif %}
{% if request.has_any_permission(('edit', 'delete', 'tag', 'lock', 'move', 'edit_title', 'remove'), topic) %}
{% if request.has_any_permission(('edit', 'delete', 'tag', 'lock', 'move', 'edit_title', 'remove', 'bookmark'), topic) %}
<menu class="post-buttons">
{% if request.has_permission('edit', topic) %}
<li><a class="post-button" name="edit"
@ -143,6 +143,28 @@
</li>
{% endif %}
{% if request.has_permission('bookmark', topic) %}
<li>
{% if not topic.bookmark_created_time %}
<a class="post-button"
data-ic-put-to="{{ request.route_url(
'ic_topic_bookmark',
topic_id36=topic.topic_id36,
) }}"
data-ic-replace-target="true"
>Bookmark</a>
{% else %}
<a class="post-button"
data-ic-delete-from="{{ request.route_url(
'ic_topic_bookmark',
topic_id36=topic.topic_id36,
) }}"
data-ic-replace-target="true"
>Unbookmark</a>
{% endif %}
</li>
{% endif %}
{% if request.has_permission("remove", topic) %}
<li>
{% if not topic.is_removed %}

63
tildes/tildes/views/api/web/comment.py

@ -18,6 +18,7 @@ from tildes.enums import CommentNotificationType, CommentLabelOption, LogEventTy
from tildes.lib.datetime import utc_now
from tildes.models.comment import (
Comment,
CommentBookmark,
CommentLabel,
CommentNotification,
CommentVote,
@ -392,3 +393,65 @@ def delete_comment_remove(request: Request) -> Response:
request.db_session.add(LogComment(LogEventType.COMMENT_UNREMOVE, request, comment))
return Response("Un-removed")
@ic_view_config(
route_name="comment_bookmark",
request_method="PUT",
permission="bookmark",
renderer="comment_contents.jinja2",
)
def put_comment_bookmark(request: Request) -> dict:
"""Bookmark a comment with Intercooler."""
comment = request.context
savepoint = request.tm.savepoint()
bookmark = CommentBookmark(request.user, comment)
request.db_session.add(bookmark)
try:
# manually flush before attempting to commit, to avoid having all
# objects detached from the session in case of an error
request.db_session.flush()
request.tm.commit()
except IntegrityError:
# the user has already bookmarked this comment
savepoint.rollback()
# re-query the comment to get complete data
comment = (
request.query(Comment)
.join_all_relationships()
.filter_by(comment_id=comment.comment_id)
.one()
)
return {"comment": comment}
@ic_view_config(
route_name="comment_bookmark",
request_method="DELETE",
permission="bookmark",
renderer="comment_contents.jinja2",
)
def delete_comment_bookmark(request: Request) -> dict:
"""Unbookmark a comment with Intercooler."""
comment = request.context
request.query(CommentBookmark).filter(
CommentBookmark.user == request.user, CommentBookmark.comment == comment
).delete(synchronize_session=False)
# commit and then re-query the comment to get complete data
request.tm.commit()
comment = (
request.query(Comment)
.join_all_relationships()
.filter_by(comment_id=comment.comment_id)
.one()
)
return {"comment": comment}

40
tildes/tildes/views/api/web/topic.py

@ -14,7 +14,7 @@ from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType
from tildes.models.group import Group
from tildes.models.log import LogTopic
from tildes.models.topic import Topic, TopicVote
from tildes.models.topic import Topic, TopicBookmark, TopicVote
from tildes.schemas.group import GroupSchema
from tildes.schemas.topic import TopicSchema
from tildes.views import IC_NOOP
@ -314,3 +314,41 @@ def patch_topic_title(request: Request, title: str) -> dict:
topic.title = title
return Response(topic.title)
@ic_view_config(
route_name="topic_bookmark", request_method="PUT", permission="bookmark"
)
def put_topic_bookmark(request: Request) -> Response:
"""Bookmark a topic with Intercooler."""
topic = request.context
savepoint = request.tm.savepoint()
bookmark = TopicBookmark(request.user, topic)
request.db_session.add(bookmark)
try:
# manually flush before attempting to commit, to avoid having all
# objects detached from the session in case of an error
request.db_session.flush()
request.tm.commit()
except IntegrityError:
# the user has already bookmarked this topic
savepoint.rollback()
return Response("Bookmarked")
@ic_view_config(
route_name="topic_bookmark", request_method="DELETE", permission="bookmark"
)
def delete_topic_bookmark(request: Request) -> Response:
"""Unbookmark a topic with Intercooler."""
topic = request.context
request.query(TopicBookmark).filter(
TopicBookmark.user == request.user, TopicBookmark.topic == topic
).delete(synchronize_session=False)
return Response("Unbookmarked")

69
tildes/tildes/views/bookmarks.py

@ -0,0 +1,69 @@
"""Views relating to bookmarks."""
from typing import Optional, Type, 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 import exists, desc
from sqlalchemy.sql.expression import and_
from webargs.pyramidparser import use_kwargs
from tildes.models.comment import Comment, CommentBookmark
from tildes.models.topic import Topic, TopicBookmark
from tildes.schemas.listing import PaginatedListingSchema
@view_config(route_name="bookmarks", renderer="bookmarks.jinja2")
@use_kwargs(PaginatedListingSchema)
@use_kwargs(
{"post_type": String(load_from="type", validate=OneOf(("topic", "comment")))}
)
def get_bookmarks(
request: Request,
after: str,
before: str,
per_page: int,
post_type: Optional[str] = None,
) -> dict:
"""Generate the bookmarks page."""
user = request.user
bookmark_cls: Union[Type[CommentBookmark], Type[TopicBookmark]]
if post_type == "comment":
post_cls = Comment
bookmark_cls = CommentBookmark
else:
post_cls = Topic
bookmark_cls = TopicBookmark
query = (
request.query(post_cls)
.filter(
exists()
.where(
and_(
bookmark_cls.user == user,
bookmark_cls.topic_id == post_cls.topic_id
if post_cls == Topic
else bookmark_cls.comment_id == post_cls.comment_id,
)
)
.correlate(bookmark_cls)
)
.order_by(desc(bookmark_cls.created_time))
)
if before:
query = query.before_id36(before)
if after:
query = query.after_id36(after)
query = query.join_all_relationships()
posts = query.get_page(per_page)
return {"user": user, "posts": posts, "post_type": post_type}
Loading…
Cancel
Save