mirror of https://gitlab.com/tildes/tildes.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
249 lines
8.9 KiB
249 lines
8.9 KiB
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Contains the TopicQuery class."""
|
|
|
|
from __future__ import annotations
|
|
from collections.abc import Sequence
|
|
from typing import Any
|
|
|
|
from pyramid.request import Request
|
|
from sqlalchemy import func
|
|
from sqlalchemy.sql.expression import and_, desc, label, text
|
|
|
|
from tildes.enums import TopicSortOption
|
|
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_ignore import TopicIgnore
|
|
from .topic_visit import TopicVisit
|
|
from .topic_vote import TopicVote
|
|
|
|
|
|
class TopicQuery(PaginatedQuery):
|
|
"""Specialized query class for Topics."""
|
|
|
|
def __init__(self, request: Request):
|
|
"""Initialize a TopicQuery for the request.
|
|
|
|
If the user is logged in, additional user-specific data will be fetched along
|
|
with the topics. For the moment, this is whether the user has voted on the
|
|
topics, and data related to their last visit - what time they last visited, and
|
|
how many new comments have been posted since.
|
|
"""
|
|
super().__init__(Topic, request)
|
|
|
|
self._only_bookmarked = False
|
|
self._only_ignored = False
|
|
self._only_user_voted = False
|
|
|
|
self.filter_ignored = False
|
|
|
|
def _attach_extra_data(self) -> TopicQuery:
|
|
"""Attach the extra user data to the query."""
|
|
if not self.request.user:
|
|
return self
|
|
|
|
# pylint: disable=protected-access
|
|
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."""
|
|
# pylint: disable=self-cls-assignment
|
|
self = super()._finalize()
|
|
|
|
if self.filter_ignored and self.request.user:
|
|
self = self.filter(TopicIgnore.topic_id == None) # noqa
|
|
|
|
return self
|
|
|
|
def _attach_vote_data(self) -> TopicQuery:
|
|
"""Join the data related to whether the user has voted on the topic."""
|
|
query = self.join(
|
|
TopicVote,
|
|
and_(
|
|
TopicVote.topic_id == Topic.topic_id,
|
|
TopicVote.user == self.request.user,
|
|
),
|
|
isouter=(not self._only_user_voted),
|
|
)
|
|
query = query.add_columns(label("voted_time", TopicVote.created_time))
|
|
|
|
return query
|
|
|
|
def _attach_bookmark_data(self) -> TopicQuery:
|
|
"""Join the data related to whether the user has bookmarked the topic."""
|
|
query = self.join(
|
|
TopicBookmark,
|
|
and_(
|
|
TopicBookmark.topic_id == Topic.topic_id,
|
|
TopicBookmark.user == self.request.user,
|
|
),
|
|
isouter=(not self._only_bookmarked),
|
|
)
|
|
query = query.add_columns(label("bookmarked_time", 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)."""
|
|
# subquery using LATERAL to select only the newest visit for each topic
|
|
lateral_subquery = (
|
|
self.request.db_session.query(
|
|
TopicVisit.visit_time, TopicVisit.num_comments
|
|
)
|
|
.filter(
|
|
TopicVisit.topic_id == Topic.topic_id,
|
|
TopicVisit.user == self.request.user,
|
|
)
|
|
.order_by(desc(TopicVisit.visit_time))
|
|
.limit(1)
|
|
.correlate(Topic)
|
|
.subquery()
|
|
.lateral()
|
|
)
|
|
|
|
# join on "true" since the subquery already restricts to the row we want
|
|
query = self.outerjoin(lateral_subquery, text("true"))
|
|
|
|
query = query.add_columns(lateral_subquery)
|
|
|
|
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
|
|
def _process_result(result: Any) -> Topic:
|
|
"""Merge additional user-context data in result onto the topic."""
|
|
if isinstance(result, Topic):
|
|
# 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
|
|
topic.user_ignored = False
|
|
else:
|
|
topic = result.Topic
|
|
|
|
topic.user_voted = bool(result.voted_time)
|
|
topic.user_bookmarked = bool(result.bookmarked_time)
|
|
topic.user_ignored = bool(result.ignored_time)
|
|
|
|
topic.last_visit_time = result.visit_time
|
|
|
|
topic.comments_since_last_visit = None
|
|
if result.num_comments is not None:
|
|
new_comments = topic.num_comments - result.num_comments
|
|
# prevent showing negative "new comments" due to deletions
|
|
topic.comments_since_last_visit = max(new_comments, 0)
|
|
|
|
return topic
|
|
|
|
def apply_sort_option(
|
|
self, sort: TopicSortOption, is_desc: bool = True
|
|
) -> TopicQuery:
|
|
"""Apply a TopicSortOption sorting method (generative)."""
|
|
if sort == TopicSortOption.VOTES:
|
|
self._sort_column = Topic.num_votes
|
|
elif sort == TopicSortOption.COMMENTS:
|
|
self._sort_column = Topic.num_comments
|
|
elif sort == TopicSortOption.NEW:
|
|
self._sort_column = Topic.created_time
|
|
elif sort == TopicSortOption.ACTIVITY:
|
|
self._sort_column = Topic.last_interesting_activity_time
|
|
elif sort == TopicSortOption.ALL_ACTIVITY:
|
|
self._sort_column = Topic.last_activity_time
|
|
|
|
self.sort_desc = is_desc
|
|
|
|
return self
|
|
|
|
def inside_groups(
|
|
self, groups: Sequence[Group], include_subgroups: bool = True
|
|
) -> TopicQuery:
|
|
"""Restrict the topics to inside specific groups (generative)."""
|
|
if include_subgroups:
|
|
query_paths = [group.path for group in groups]
|
|
group_ids = self.request.db_session.query(Group.group_id).filter(
|
|
Group.path.descendant_of(query_paths)
|
|
)
|
|
else:
|
|
group_ids = [group.group_id for group in groups]
|
|
|
|
return self.filter(Topic.group_id.in_(group_ids)) # type: ignore
|
|
|
|
def inside_time_period(self, period: SimpleHoursPeriod) -> TopicQuery:
|
|
"""Restrict the topics to inside a time period (generative)."""
|
|
# if the time period is too long, this will crash by creating a datetime outside
|
|
# the valid range - catch that and just don't filter by time period at all if
|
|
# the range is that large
|
|
try:
|
|
start_time = utc_now() - period.timedelta
|
|
except OverflowError:
|
|
return self
|
|
|
|
return self.filter(Topic.created_time > start_time)
|
|
|
|
def has_tag(self, tag: str) -> TopicQuery:
|
|
"""Restrict the topics to ones with a specific tag (generative).
|
|
|
|
Note that this method searches for topics that have any tag that contains
|
|
the specified tag as a subpath, not only exact/full matches.
|
|
"""
|
|
query = f"*.{tag}.*"
|
|
|
|
# pylint: disable=protected-access
|
|
return self.filter(Topic.tags.lquery(query)) # type: ignore
|
|
|
|
def search(self, query: str) -> TopicQuery:
|
|
"""Restrict the topics to ones that match a search query (generative)."""
|
|
# Replace "." with space, since tags are stored as space-separated strings
|
|
# in the search index.
|
|
# URL domains are not indexed, so removing "." is okay for now.
|
|
query = query.replace(".", " ")
|
|
|
|
return self.filter(Topic.search_tsv.op("@@")(func.websearch_to_tsquery(query)))
|
|
|
|
def only_bookmarked(self) -> TopicQuery:
|
|
"""Restrict the topics to ones that the user has bookmarked (generative)."""
|
|
self._only_bookmarked = True
|
|
return self
|
|
|
|
def only_user_voted(self) -> TopicQuery:
|
|
"""Restrict the topics to ones that the user has voted on (generative)."""
|
|
self._only_user_voted = True
|
|
return self
|
|
|
|
def only_ignored(self) -> TopicQuery:
|
|
"""Restrict the topics to ones that the user has ignored (generative)."""
|
|
# pylint: disable=self-cls-assignment
|
|
self._only_ignored = True
|
|
|
|
return self
|
|
|
|
def exclude_ignored(self) -> TopicQuery:
|
|
"""Specify that ignored topics should be excluded (generative)."""
|
|
self.filter_ignored = True
|
|
|
|
return self
|