Browse Source

Add simple functionality for ignoring topics

This adds a simple Ignore functionality for topics, with the only effect
being to exclude ignored topics from listing pages.

It also adds a "Your ignored topics" page (linked through the user menu)
that lists all of a user's ignored topics, so that they can access them
if needed (to be able to un-ignore).
merge-requests/88/head
Ivan Fonseca 6 years ago
committed by Deimos
parent
commit
75c0fa0471
  1. 50
      tildes/alembic/versions/4e101aae77cd_add_topic_ignores.py
  2. 1
      tildes/tildes/database_models.py
  3. 1
      tildes/tildes/models/topic/__init__.py
  4. 12
      tildes/tildes/models/topic/topic.py
  5. 38
      tildes/tildes/models/topic/topic_ignore.py
  6. 50
      tildes/tildes/models/topic/topic_query.py
  7. 2
      tildes/tildes/request_methods.py
  8. 1
      tildes/tildes/resources/topic.py
  9. 2
      tildes/tildes/routes.py
  10. 43
      tildes/tildes/templates/ignored_topics.jinja2
  11. 3
      tildes/tildes/templates/macros/buttons.jinja2
  12. 5
      tildes/tildes/templates/macros/user_menu.jinja2
  13. 4
      tildes/tildes/templates/topic.jinja2
  14. 46
      tildes/tildes/views/api/web/topic.py
  15. 35
      tildes/tildes/views/ignored_topics.py

50
tildes/alembic/versions/4e101aae77cd_add_topic_ignores.py

@ -0,0 +1,50 @@
"""Add topic_ignores
Revision ID: 4e101aae77cd
Revises: 4d352e61a468
Create Date: 2020-01-07 23:07:51.707034
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "4e101aae77cd"
down_revision = "4d352e61a468"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"topic_ignores",
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_ignores_topic_id_topics"),
),
sa.ForeignKeyConstraint(
["user_id"], ["users.user_id"], name=op.f("fk_topic_ignores_user_id_users")
),
sa.PrimaryKeyConstraint("user_id", "topic_id", name=op.f("pk_topic_ignores")),
)
op.create_index(
op.f("ix_topic_ignores_created_time"),
"topic_ignores",
["created_time"],
unique=False,
)
def downgrade():
op.drop_index(op.f("ix_topic_ignores_created_time"), table_name="topic_ignores")
op.drop_table("topic_ignores")

1
tildes/tildes/database_models.py

@ -20,6 +20,7 @@ from tildes.models.scraper import ScraperResult
from tildes.models.topic import ( from tildes.models.topic import (
Topic, Topic,
TopicBookmark, TopicBookmark,
TopicIgnore,
TopicSchedule, TopicSchedule,
TopicVisit, TopicVisit,
TopicVote, TopicVote,

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

@ -2,6 +2,7 @@
from .topic import EDIT_GRACE_PERIOD, Topic, VOTING_PERIOD from .topic import EDIT_GRACE_PERIOD, Topic, VOTING_PERIOD
from .topic_bookmark import TopicBookmark from .topic_bookmark import TopicBookmark
from .topic_ignore import TopicIgnore
from .topic_query import TopicQuery from .topic_query import TopicQuery
from .topic_schedule import TopicSchedule from .topic_schedule import TopicSchedule
from .topic_visit import TopicVisit from .topic_visit import TopicVisit

12
tildes/tildes/models/topic/topic.py

@ -326,6 +326,14 @@ class Topic(DatabaseModel):
acl.append((Allow, "admin", "tag")) acl.append((Allow, "admin", "tag"))
acl.append((Allow, "topic.tag", "tag")) acl.append((Allow, "topic.tag", "tag"))
# bookmark:
# - logged-in users can bookmark topics
acl.append((Allow, Authenticated, "bookmark"))
# ignore:
# - logged-in users can ignore topics
acl.append((Allow, Authenticated, "ignore"))
# edit_title: # edit_title:
# - allow admins or people with the "topic.edit_title" permission to always # - allow admins or people with the "topic.edit_title" permission to always
# edit titles # edit titles
@ -350,10 +358,6 @@ class Topic(DatabaseModel):
acl.append((Allow, "admin", "edit_link")) acl.append((Allow, "admin", "edit_link"))
acl.append((Allow, "topic.edit_link", "edit_link")) acl.append((Allow, "topic.edit_link", "edit_link"))
# bookmark:
# - logged-in users can bookmark topics
acl.append((Allow, Authenticated, "bookmark"))
acl.append(DENY_ALL) acl.append(DENY_ALL)
return acl return acl

38
tildes/tildes/models/topic/topic_ignore.py

@ -0,0 +1,38 @@
"""Contains the TopicIgnore 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 TopicIgnore(DatabaseModel):
"""Model for an ignored topic."""
__tablename__ = "topic_ignores"
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:
"""Ignore a topic."""
self.user = user
self.topic = topic

50
tildes/tildes/models/topic/topic_query.py

@ -16,6 +16,7 @@ from tildes.models.pagination import PaginatedQuery
from .topic import Topic from .topic import Topic
from .topic_bookmark import TopicBookmark from .topic_bookmark import TopicBookmark
from .topic_ignore import TopicIgnore
from .topic_visit import TopicVisit from .topic_visit import TopicVisit
from .topic_vote import TopicVote from .topic_vote import TopicVote
@ -34,15 +35,33 @@ class TopicQuery(PaginatedQuery):
super().__init__(Topic, request) super().__init__(Topic, request)
self._only_bookmarked = False self._only_bookmarked = False
self._only_ignored = False
self._only_user_voted = False self._only_user_voted = False
# filter out ignored topics by default for logged-in users
self.filter_ignored = bool(request.user)
def _attach_extra_data(self) -> "TopicQuery": def _attach_extra_data(self) -> "TopicQuery":
"""Attach the extra user data to the query.""" """Attach the extra user data to the query."""
if not self.request.user: if not self.request.user:
return self return self
# pylint: disable=protected-access # pylint: disable=protected-access
return self._attach_vote_data()._attach_visit_data()._attach_bookmark_data()
return (
self._attach_vote_data()
._attach_visit_data()
._attach_bookmark_data()
._attach_ignored_data()
)
def _finalize(self) -> "TopicQuery":
"""Finalize the query before it's executed."""
self = super()._finalize()
if self.filter_ignored:
self = self.filter(TopicIgnore.created_time == None)
return self
def _attach_vote_data(self) -> "TopicQuery": def _attach_vote_data(self) -> "TopicQuery":
"""Join the data related to whether the user has voted on the topic.""" """Join the data related to whether the user has voted on the topic."""
@ -92,6 +111,20 @@ class TopicQuery(PaginatedQuery):
return query return query
def _attach_ignored_data(self) -> "TopicQuery":
"""Join the data related to whether the user has ignored the topic."""
query = self.join(
TopicIgnore,
and_(
TopicIgnore.topic_id == Topic.topic_id,
TopicIgnore.user == self.request.user,
),
isouter=(not self._only_ignored),
)
query = query.add_columns(label("ignored_time", TopicIgnore.created_time))
return query
@staticmethod @staticmethod
def _process_result(result: Any) -> Topic: def _process_result(result: Any) -> Topic:
"""Merge additional user-context data in result onto the topic.""" """Merge additional user-context data in result onto the topic."""
@ -102,11 +135,13 @@ class TopicQuery(PaginatedQuery):
topic.bookmark_created_time = None topic.bookmark_created_time = None
topic.last_visit_time = None topic.last_visit_time = None
topic.comments_since_last_visit = None topic.comments_since_last_visit = None
topic.user_ignored = False
else: else:
topic = result.Topic topic = result.Topic
topic.user_voted = bool(result.voted_time) topic.user_voted = bool(result.voted_time)
topic.user_bookmarked = bool(result.bookmarked_time) topic.user_bookmarked = bool(result.bookmarked_time)
topic.user_ignored = bool(result.ignored_time)
topic.last_visit_time = result.visit_time topic.last_visit_time = result.visit_time
@ -187,3 +222,16 @@ class TopicQuery(PaginatedQuery):
"""Restrict the topics to ones that the user has voted on (generative).""" """Restrict the topics to ones that the user has voted on (generative)."""
self._only_user_voted = True self._only_user_voted = True
return self return self
def only_ignored(self) -> "TopicQuery":
"""Restrict the topics to ones that the user has ignored (generative)."""
self._only_ignored = True
self = self.include_ignored()
return self
def include_ignored(self) -> "TopicQuery":
"""Specify that ignored topics should be included (generative)."""
self.filter_ignored = False
return self

2
tildes/tildes/request_methods.py

@ -122,6 +122,7 @@ def current_listing_base_url(
"group": ("order", "period", "per_page", "tag", "unfiltered"), "group": ("order", "period", "per_page", "tag", "unfiltered"),
"group_search": ("order", "period", "per_page", "q"), "group_search": ("order", "period", "per_page", "q"),
"home": ("order", "period", "per_page", "tag", "unfiltered"), "home": ("order", "period", "per_page", "tag", "unfiltered"),
"ignored_topics": ("per_page",),
"search": ("order", "period", "per_page", "q"), "search": ("order", "period", "per_page", "q"),
"user": ("order", "per_page", "type"), "user": ("order", "per_page", "type"),
"user_search": ("order", "per_page", "type", "q"), "user_search": ("order", "per_page", "type", "q"),
@ -157,6 +158,7 @@ def current_listing_normal_url(
"group": ("order", "period", "per_page"), "group": ("order", "period", "per_page"),
"group_search": ("order", "period", "per_page", "q"), "group_search": ("order", "period", "per_page", "q"),
"home": ("order", "period", "per_page"), "home": ("order", "period", "per_page"),
"ignored_topics": ("order", "period", "per_page"),
"notifications": ("per_page",), "notifications": ("per_page",),
"search": ("order", "period", "per_page", "q"), "search": ("order", "period", "per_page", "q"),
"user": ("order", "per_page"), "user": ("order", "per_page"),

1
tildes/tildes/resources/topic.py

@ -25,6 +25,7 @@ def topic_by_id36(request: Request, topic_id36: str) -> Topic:
request.query(Topic) request.query(Topic)
.include_deleted() .include_deleted()
.include_removed() .include_removed()
.include_ignored()
.filter_by(topic_id=topic_id) .filter_by(topic_id=topic_id)
) )

2
tildes/tildes/routes.py

@ -106,6 +106,7 @@ def includeme(config: Configurator) -> None:
) )
config.add_route("bookmarks", "/bookmarks", factory=LoggedInFactory) config.add_route("bookmarks", "/bookmarks", factory=LoggedInFactory)
config.add_route("ignored_topics", "/ignored_topics", factory=LoggedInFactory)
config.add_route("votes", "/votes", factory=LoggedInFactory) config.add_route("votes", "/votes", factory=LoggedInFactory)
config.add_route("invite", "/invite", factory=LoggedInFactory) config.add_route("invite", "/invite", factory=LoggedInFactory)
@ -150,6 +151,7 @@ def add_intercooler_routes(config: Configurator) -> None:
add_ic_route("topic_vote", "/vote", factory=topic_by_id36) add_ic_route("topic_vote", "/vote", factory=topic_by_id36)
add_ic_route("topic_tags", "/tags", factory=topic_by_id36) add_ic_route("topic_tags", "/tags", factory=topic_by_id36)
add_ic_route("topic_bookmark", "/bookmark", factory=topic_by_id36) add_ic_route("topic_bookmark", "/bookmark", factory=topic_by_id36)
add_ic_route("topic_ignore", "/ignore", factory=topic_by_id36)
add_ic_route("comment", "/comments/{comment_id36}", factory=comment_by_id36) add_ic_route("comment", "/comments/{comment_id36}", factory=comment_by_id36)
with config.route_prefix_context("/comments/{comment_id36}"): with config.route_prefix_context("/comments/{comment_id36}"):

43
tildes/tildes/templates/ignored_topics.jinja2

@ -0,0 +1,43 @@
{% extends 'base_user_menu.jinja2' %}
{% from 'macros/topics.jinja2' import render_topic_for_listing with context %}
{% block title %}Ignored Topics{% endblock %}
{% block main_heading %}Ignored Topics{% endblock %}
{% block content %}
{% if topics %}
<ol class="post-listing">
{% for topic in topics if request.has_permission('view', topic) %}
<li>{{ render_topic_for_listing(topic, show_group=True) }}</li>
{% endfor %}
</ol>
{% if topics.has_prev_page or topics.has_next_page %}
<div class="pagination">
{% if topics.has_prev_page %}
<a class="page-item btn"
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"
href="{{ request.current_listing_base_url({'after': topics.next_page_after_id36}) }}"
>Next</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty">
<h2 class="empty-title">
{% block empty_message %}
You haven't ignored any topics
{% endblock %}
</h2>
</div>
{% endif %}
{% endblock %}

3
tildes/tildes/templates/macros/buttons.jinja2

@ -12,6 +12,9 @@
{% if name == "bookmark" %} {% if name == "bookmark" %}
{% set normal_label = "Bookmark" %} {% set normal_label = "Bookmark" %}
{% set toggled_label = "Unbookmark" %} {% set toggled_label = "Unbookmark" %}
{% elif name == "ignore" %}
{% set normal_label = "Ignore" %}
{% set toggled_label = "Un-ignore" %}
{% elif name == "lock" %} {% elif name == "lock" %}
{% set normal_label = "Lock" %} {% set normal_label = "Lock" %}
{% set toggled_label = "Unlock" %} {% set toggled_label = "Unlock" %}

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

@ -23,6 +23,11 @@
Your votes Your votes
</a> </a>
</li> </li>
<li class="nav-item {{ 'active' if route == 'ignored_topics' else '' }}">
<a href="/ignored_topics">
Your ignored topics
</a>
</li>
</ul> </ul>
<li>Notifications</li> <li>Notifications</li>

4
tildes/tildes/templates/topic.jinja2

@ -165,6 +165,10 @@
{{ post_action_toggle_button("bookmark", topic, is_toggled=topic.user_bookmarked) }} {{ post_action_toggle_button("bookmark", topic, is_toggled=topic.user_bookmarked) }}
{% endif %} {% endif %}
{% if request.has_permission("ignore", topic) %}
{{ post_action_toggle_button("ignore", topic, is_toggled=topic.user_ignored) }}
{% endif %}
{% if request.has_permission("remove", topic) %} {% if request.has_permission("remove", topic) %}
{{ post_action_toggle_button("remove", topic, topic.is_removed) }} {{ post_action_toggle_button("remove", topic, topic.is_removed) }}
{% endif %} {% endif %}

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

@ -14,7 +14,7 @@ from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType from tildes.enums import LogEventType
from tildes.models.group import Group from tildes.models.group import Group
from tildes.models.log import LogTopic from tildes.models.log import LogTopic
from tildes.models.topic import Topic, TopicBookmark, TopicVote
from tildes.models.topic import Topic, TopicBookmark, TopicIgnore, TopicVote
from tildes.schemas.group import GroupSchema from tildes.schemas.group import GroupSchema
from tildes.schemas.topic import TopicSchema from tildes.schemas.topic import TopicSchema
from tildes.views import IC_NOOP from tildes.views import IC_NOOP
@ -438,3 +438,47 @@ def delete_topic_bookmark(request: Request) -> dict:
).delete(synchronize_session=False) ).delete(synchronize_session=False)
return {"name": "bookmark", "subject": topic, "is_toggled": False} return {"name": "bookmark", "subject": topic, "is_toggled": False}
@ic_view_config(
route_name="topic_ignore",
request_method="PUT",
permission="ignore",
renderer="post_action_toggle_button.jinja2",
)
def put_topic_ignore(request: Request) -> dict:
"""Ignore a topic with Intercooler."""
topic = request.context
savepoint = request.tm.savepoint()
ignore = TopicIgnore(request.user, topic)
request.db_session.add(ignore)
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 ignored this topic
savepoint.rollback()
return {"name": "ignore", "subject": topic, "is_toggled": True}
@ic_view_config(
route_name="topic_ignore",
request_method="DELETE",
permission="ignore",
renderer="post_action_toggle_button.jinja2",
)
def delete_topic_ignore(request: Request) -> dict:
"""Unignore a topic with Intercooler."""
topic = request.context
request.query(TopicIgnore).filter(
TopicIgnore.user == request.user, TopicIgnore.topic == topic
).delete(synchronize_session=False)
return {"name": "ignore", "subject": topic, "is_toggled": False}

35
tildes/tildes/views/ignored_topics.py

@ -0,0 +1,35 @@
"""Views relating to ignored topics."""
from typing import Optional, Type, Union
from pyramid.request import Request
from pyramid.view import view_config
from sqlalchemy.sql import desc
from webargs.pyramidparser import use_kwargs
from tildes.models.topic import Topic, TopicIgnore
from tildes.schemas.listing import PaginatedListingSchema
@view_config(route_name="ignored_topics", renderer="ignored_topics.jinja2")
@use_kwargs(PaginatedListingSchema)
def get_ignored_topics(
request: Request, after: Optional[str], before: Optional[str], per_page: int,
) -> dict:
"""Generate the ignored topics page."""
# pylint: disable=unused-argument
user = request.user
query = request.query(Topic).only_ignored().order_by(desc(TopicIgnore.created_time))
if before:
query = query.before_id36(before)
if after:
query = query.after_id36(after)
query = query.join_all_relationships()
topics = query.all()
return {"user": user, "topics": topics}
Loading…
Cancel
Save