diff --git a/tildes/tildes/models/topic/topic_query.py b/tildes/tildes/models/topic/topic_query.py index 8eb63a8..804f9a5 100644 --- a/tildes/tildes/models/topic/topic_query.py +++ b/tildes/tildes/models/topic/topic_query.py @@ -193,6 +193,14 @@ class TopicQuery(PaginatedQuery): return self.filter(Topic.group_id.in_(group_ids)) # type: ignore + def has_any_groups(self, group_ids: list[int]) -> TopicQuery: + """Restrict the topics to those with any of the given group IDs (generative).""" + return self.filter(Topic.group_id.in_(group_ids)) # type: ignore + + def not_has_any_groups(self, group_ids: list[int]) -> TopicQuery: + """Restrict to topics those given group IDs (generative).""" + 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 @@ -216,6 +224,40 @@ class TopicQuery(PaginatedQuery): # pylint: disable=protected-access return self.filter(Topic.tags.lquery(query)) # type: ignore + def has_any_tags(self, tags: list[str]) -> TopicQuery: + """Restrict the topics to ones with any of the specified tags (generative). + + Tags can be any valid ltree, including wildcards, e.g. 'ask.*'. + """ + + return self.filter(Topic.tags.lquery(tags)) # type: ignore + + def has_all_tags(self, tags: list[str]) -> TopicQuery: + """Restrict the topics to ones with all of the specified tags (generative). + + Tags can be any valid ltree, including wildcards, e.g. 'ask.*'. + """ + + tag_queries = [Topic.tags.lquery(tag) for tag in tags] # type: ignore + # For example, + # ["ask.*", "japanese"] + # produces (rough) SQL + # (topics.tags ~ 'ask.*' AND topic.tags ~ 'japanese') + return self.filter(and_(*tag_queries)) + + def not_has_any_tags(self, tags: list[str]) -> TopicQuery: + """Restrict the topics to ones without any of the specified tags (generative). + + Tags can be any valid ltree, including wildcards, e.g. 'ask.*'. + """ + + return self.filter(~Topic.tags.lquery(tags)) # type: ignore + + def posted_by_any_users(self, user_ids: list[int]) -> TopicQuery: + """Restrict the topics to ones posted by the specified user (generative).""" + + return self.filter(Topic.user_id.in_(user_ids)) # 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 diff --git a/tildes/tildes/request_methods.py b/tildes/tildes/request_methods.py index f6e25ae..5ed8aa0 100644 --- a/tildes/tildes/request_methods.py +++ b/tildes/tildes/request_methods.py @@ -124,6 +124,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, ...]] = { + "advanced_search": ("order", "period", "per_page"), "bookmarks": ("per_page", "type"), "group": ("order", "period", "per_page", "tag", "unfiltered"), "group_search": ("order", "period", "per_page", "q"), diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index bd8b8f4..2088edf 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -23,6 +23,7 @@ def includeme(config: Configurator) -> None: config.add_route("home_rss", "/topics.rss") config.add_route("search", "/search") + config.add_route("advanced_search", "/advanced_search") config.add_route("financials", "/financials") diff --git a/tildes/tildes/schemas/listing.py b/tildes/tildes/schemas/listing.py index c11508f..adafaf7 100644 --- a/tildes/tildes/schemas/listing.py +++ b/tildes/tildes/schemas/listing.py @@ -6,7 +6,7 @@ from typing import Any from marshmallow import pre_load, Schema, validates_schema, ValidationError -from marshmallow.fields import Boolean, Integer +from marshmallow.fields import Boolean, Integer, String, List from marshmallow.validate import Range from tildes.enums import TopicSortOption @@ -36,6 +36,12 @@ class TopicListingSchema(PaginatedListingSchema): tag = Ltree(missing=None) unfiltered = Boolean(missing=False) rank_start = Integer(data_key="n", validate=Range(min=1), missing=None) + group_ids = List(Integer(), missing=None) + not_group_ids = List(Integer(), missing=None) + tags = List(String(), missing=None) + all_tags = List(String(), missing=None) + not_tags = List(String(), missing=None) + user_ids = List(Integer(), missing=None) @pre_load def reset_rank_start_on_first_page( diff --git a/tildes/tildes/views/topic.py b/tildes/tildes/views/topic.py index 0d59776..0ff7e20 100644 --- a/tildes/tildes/views/topic.py +++ b/tildes/tildes/views/topic.py @@ -157,7 +157,7 @@ def get_group_topics( # noqa rank_start: Optional[int], tag: Optional[Ltree], unfiltered: bool, - **kwargs: Any + **kwargs: Any, ) -> dict: """Get a listing of topics in the group.""" # period needs special treatment so we can distinguish between missing and None @@ -336,7 +336,7 @@ def get_search( before: Optional[str], per_page: int, search: str, - **kwargs: Any + **kwargs: Any, ) -> dict: """Get a list of search results.""" # period needs special treatment so we can distinguish between missing and None @@ -394,6 +394,99 @@ def get_search( } +@view_config(route_name="advanced_search", renderer="search.jinja2") +@use_kwargs( + TopicListingSchema( + only=( + "after", + "before", + "order", + "per_page", + "period", + "group_ids", + "not_group_ids", + "tags", + "all_tags", + "not_tags", + "user_ids", + ) + ) +) +def get_advanced_search( # noqa + request: Request, + group_ids: Optional[list[int]], + not_group_ids: Optional[list[int]], + tags: Optional[list[str]], + all_tags: Optional[list[str]], + not_tags: Optional[list[str]], + user_ids: Optional[list[int]], + order: Optional[TopicSortOption], + after: Optional[str], + before: Optional[str], + per_page: int, + **kwargs: Any, +) -> dict: + """Get a list of advanced search results.""" + # period needs special treatment so we can distinguish between missing and None + period = kwargs.get("period", missing) + + if not order: + order = TopicSortOption.NEW + + if period is missing: + period = None + + query = request.query(Topic).join_all_relationships().apply_sort_option(order) + + if group_ids: + query = query.has_any_groups(group_ids) + + if not_group_ids: + query = query.not_has_any_groups(not_group_ids) + + if tags: + query = query.has_any_tags(tags) + + if all_tags: + query = query.has_all_tags(all_tags) + + if not_tags: + query = query.not_has_any_tags(not_tags) + + if user_ids: + query = query.posted_by_any_users(user_ids) + + # restrict the time period, if not set to "all time" + if period: + query = query.inside_time_period(period) + + # apply before/after pagination restrictions if relevant + if before: + query = query.before_id36(before) + + if after: + query = query.after_id36(after) + + topics = query.get_page(per_page) + + period_options = [SimpleHoursPeriod(hours) for hours in (1, 12, 24, 72)] + + # add the current period to the bottom of the dropdown if it's not one of the + # "standard" ones + if period and period not in period_options: + period_options.append(period) + + return { + "search": "", + "topics": topics, + "group": None, + "order": order, + "order_options": TopicSortOption, + "period": period, + "period_options": period_options, + } + + @view_config( route_name="new_topic", renderer="new_topic.jinja2", permission="topic.post" )