Browse Source

Allow users to search their own posts

This actually involves the necessary backend changes for full comment
search, but I'm a little nervous about the privacy implications of that,
and don't want to enable it for everyone out of nowhere. So for now,
this just allows users to search *their own* comments or topics.

These views and templates are starting to get real ugly, and probably
need a major re-thinking soon.
merge-requests/85/head
Deimos 5 years ago
parent
commit
1b89198e40
  1. 55
      tildes/alembic/versions/679090fd4977_add_search_column_index_for_comments.py
  2. 1
      tildes/prospector.yaml
  3. 16
      tildes/scss/modules/_form.scss
  4. 8
      tildes/scss/modules/_sidebar.scss
  5. 11
      tildes/sql/init/triggers/comments/comments.sql
  6. 8
      tildes/tildes/models/comment/comment.py
  7. 5
      tildes/tildes/models/comment/comment_query.py
  8. 2
      tildes/tildes/request_methods.py
  9. 1
      tildes/tildes/routes.py
  10. 29
      tildes/tildes/templates/user.jinja2
  11. 10
      tildes/tildes/templates/user_search.jinja2
  12. 68
      tildes/tildes/views/user.py

55
tildes/alembic/versions/679090fd4977_add_search_column_index_for_comments.py

@ -0,0 +1,55 @@
"""Add search column/index for comments
Revision ID: 679090fd4977
Revises: 9fc0033a2b61
Create Date: 2019-10-12 17:46:13.418316
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "679090fd4977"
down_revision = "9fc0033a2b61"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"comments", sa.Column("search_tsv", postgresql.TSVECTOR(), nullable=True)
)
op.create_index(
"ix_comments_search_tsv_gin",
"comments",
["search_tsv"],
unique=False,
postgresql_using="gin",
)
op.execute(
"""
CREATE TRIGGER comment_update_search_tsv_insert
BEFORE INSERT ON comments
FOR EACH ROW
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', markdown);
CREATE TRIGGER comment_update_search_tsv_update
BEFORE UPDATE ON comments
FOR EACH ROW
WHEN (OLD.markdown IS DISTINCT FROM NEW.markdown)
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', markdown);
"""
)
# increase the timeout since updating search for all comments could take a while
op.execute("SET statement_timeout TO '10min'")
op.execute(
"UPDATE comments SET search_tsv = to_tsvector('pg_catalog.english', markdown)"
)
def downgrade():
op.drop_index("ix_comments_search_tsv_gin", table_name="comments")
op.drop_column("comments", "search_tsv")

1
tildes/prospector.yaml

@ -39,5 +39,6 @@ pylint:
- too-many-locals # almost never helpful - too-many-locals # almost never helpful
- too-many-public-methods # almost never helpful - too-many-public-methods # almost never helpful
- too-many-return-statements # almost never helpful - too-many-return-statements # almost never helpful
- too-many-statements # almost never helpful
- ungrouped-imports # let isort handle this - ungrouped-imports # let isort handle this
- unnecessary-pass # I prefer using pass, even when it's not technically necessary - unnecessary-pass # I prefer using pass, even when it's not technically necessary

16
tildes/scss/modules/_form.scss

@ -107,8 +107,20 @@ textarea.form-input {
display: block; display: block;
} }
.form-search .form-input {
margin-right: 0.4rem;
.form-search {
margin-top: 1rem;
margin-bottom: 1rem;
.btn {
width: auto;
padding: 0 0.4rem;
font-weight: normal;
}
.form-input {
max-width: 15rem;
margin-right: 0.4rem;
}
} }
#new-topic { #new-topic {

8
tildes/scss/modules/_sidebar.scss

@ -19,12 +19,10 @@
} }
.form-search { .form-search {
margin-bottom: 1rem;
margin-top: 0;
.btn {
width: auto;
padding: 0 0.4rem;
font-weight: normal;
.form-input {
max-width: unset;
} }
} }

11
tildes/sql/init/triggers/comments/comments.sql

@ -19,3 +19,14 @@ CREATE TRIGGER delete_comment_set_deleted_time_update
FOR EACH ROW FOR EACH ROW
WHEN (OLD.is_deleted IS DISTINCT FROM NEW.is_deleted) WHEN (OLD.is_deleted IS DISTINCT FROM NEW.is_deleted)
EXECUTE PROCEDURE set_comment_deleted_time(); EXECUTE PROCEDURE set_comment_deleted_time();
CREATE TRIGGER comment_update_search_tsv_insert
BEFORE INSERT ON comments
FOR EACH ROW
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', markdown);
CREATE TRIGGER comment_update_search_tsv_update
BEFORE UPDATE ON comments
FOR EACH ROW
WHEN (OLD.markdown IS DISTINCT FROM NEW.markdown)
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', markdown);

8
tildes/tildes/models/comment/comment.py

@ -8,7 +8,8 @@ from datetime import datetime, timedelta
from typing import Any, Optional, Sequence, Tuple, TYPE_CHECKING, Union from typing import Any, Optional, Sequence, Tuple, TYPE_CHECKING, Union
from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone
from sqlalchemy import Boolean, Column, ForeignKey, Integer, Text, TIMESTAMP
from sqlalchemy import Boolean, Column, ForeignKey, Index, Integer, Text, TIMESTAMP
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import deferred, relationship from sqlalchemy.orm import deferred, relationship
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
@ -87,6 +88,7 @@ class Comment(DatabaseModel):
rendered_html: str = Column(Text, nullable=False) rendered_html: str = Column(Text, nullable=False)
excerpt: str = Column(Text, nullable=False, server_default="") excerpt: str = Column(Text, nullable=False, server_default="")
num_votes: int = Column(Integer, nullable=False, server_default="0", index=True) num_votes: int = Column(Integer, nullable=False, server_default="0", index=True)
search_tsv: Any = deferred(Column(TSVECTOR))
user: User = relationship("User", lazy=False, innerjoin=True) user: User = relationship("User", lazy=False, innerjoin=True)
topic: Topic = relationship("Topic", innerjoin=True) topic: Topic = relationship("Topic", innerjoin=True)
@ -94,6 +96,10 @@ class Comment(DatabaseModel):
"Comment", uselist=False, remote_side=[comment_id] "Comment", uselist=False, remote_side=[comment_id]
) )
__table_args__ = (
Index("ix_comments_search_tsv_gin", "search_tsv", postgresql_using="gin"),
)
@hybrid_property @hybrid_property
def markdown(self) -> str: def markdown(self) -> str:
"""Return the comment's markdown.""" """Return the comment's markdown."""

5
tildes/tildes/models/comment/comment_query.py

@ -6,6 +6,7 @@
from typing import Any from typing import Any
from pyramid.request import Request from pyramid.request import Request
from sqlalchemy import func
from sqlalchemy.sql.expression import and_ from sqlalchemy.sql.expression import and_
from tildes.enums import CommentSortOption from tildes.enums import CommentSortOption
@ -93,6 +94,10 @@ class CommentQuery(PaginatedQuery):
return self return self
def search(self, query: str) -> "CommentQuery":
"""Restrict the comments to ones that match a search query (generative)."""
return self.filter(Comment.search_tsv.op("@@")(func.plainto_tsquery(query)))
def only_bookmarked(self) -> "CommentQuery": def only_bookmarked(self) -> "CommentQuery":
"""Restrict the comments to ones that the user has bookmarked (generative).""" """Restrict the comments to ones that the user has bookmarked (generative)."""
self._only_bookmarked = True self._only_bookmarked = True

2
tildes/tildes/request_methods.py

@ -96,6 +96,7 @@ def current_listing_base_url(
"home": ("order", "period", "per_page", "tag", "unfiltered"), "home": ("order", "period", "per_page", "tag", "unfiltered"),
"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"),
} }
try: try:
@ -130,6 +131,7 @@ def current_listing_normal_url(
"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"),
"user_search": ("order", "per_page", "q"),
} }
try: try:

1
tildes/tildes/routes.py

@ -64,6 +64,7 @@ def includeme(config: Configurator) -> None:
with config.route_prefix_context("/user/{username}"): with config.route_prefix_context("/user/{username}"):
config.add_route("new_message", "/new_message", factory=user_by_username) config.add_route("new_message", "/new_message", factory=user_by_username)
config.add_route("user_messages", "/messages", factory=user_by_username) config.add_route("user_messages", "/messages", factory=user_by_username)
config.add_route("user_search", "/search", factory=user_by_username)
config.add_route("notifications", "/notifications", factory=LoggedInFactory) config.add_route("notifications", "/notifications", factory=LoggedInFactory)
with config.route_prefix_context("/notifications"): with config.route_prefix_context("/notifications"):

29
tildes/tildes/templates/user.jinja2

@ -42,9 +42,13 @@
{% if request.has_permission("view_history", user) %} {% if request.has_permission("view_history", user) %}
<div class="listing-options"> <div class="listing-options">
<menu class="tab tab-listing-order"> <menu class="tab tab-listing-order">
<li class="tab-item{{' active' if not post_type else ''}}">
<a href="{{ request.current_listing_normal_url() }}">All posts</a>
</li>
{# Don't show the "All posts" option in search results, since it can't be used #}
{% if search is not defined %}
<li class="tab-item{{' active' if not post_type else ''}}">
<a href="{{ request.current_listing_normal_url() }}">All posts</a>
</li>
{% endif %}
<li class="tab-item{{' active' if post_type == 'topic' else ''}}"> <li class="tab-item{{' active' if post_type == 'topic' else ''}}">
<a href="{{ request.current_listing_normal_url({'type': 'topic'}) }}">Topics</a> <a href="{{ request.current_listing_normal_url({'type': 'topic'}) }}">Topics</a>
</li> </li>
@ -56,6 +60,10 @@
{% if order_options %} {% if order_options %}
<form class="form-listing-options" method="get"> <form class="form-listing-options" method="get">
<input type="hidden" name="type" value="{{ post_type }}"> <input type="hidden" name="type" value="{{ post_type }}">
{% if search %}
<input type="hidden" name="q" value="{{ search }}">
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="order">sorted by</label> <label for="order">sorted by</label>
<select id="order" name="order" class="form-select" data-js-autosubmit-on-change> <select id="order" name="order" class="form-select" data-js-autosubmit-on-change>
@ -79,6 +87,17 @@
</div> </div>
{% endif %} {% endif %}
{% if post_type and request.has_permission("search_posts", user) %}
<form class="form-search" method="get" action="{{ request.route_url("user_search", username=user.username) }}">
<input type="hidden" name="type" value="{{ post_type }}">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q" placeholder="Search your {{ post_type }}s" value="{{ search }}">
<button class="btn btn-sm btn-link">Search</button>
</div>
</form>
{% endif %}
{% if posts %} {% if posts %}
{% if request.has_permission("view_history", user) and posts.has_prev_page %} {% if request.has_permission("view_history", user) and posts.has_prev_page %}
<div class="pagination"> <div class="pagination">
@ -132,7 +151,9 @@
{% endif %} {% endif %}
{% else %} {% else %}
<div class="empty"> <div class="empty">
<h2 class="empty-title">This user hasn't made any posts</h2>
<h2 class="empty-title">
{% block no_posts_message %}This user hasn't made any posts{% endblock %}
</h2>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}

10
tildes/tildes/templates/user_search.jinja2

@ -0,0 +1,10 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'user.jinja2' %}
{% block title %}Search {{ user }}'s posts for "{{ search }}"{% endblock %}
{% block main_heading %}Search results for "{{ search }}"{% endblock %}
{% block no_posts_message %}No results found{% endblock %}

68
tildes/tildes/views/user.py

@ -86,6 +86,70 @@ def get_user(
} }
@view_config(
route_name="user_search", renderer="user_search.jinja2", permission="search_posts"
)
@use_kwargs(MixedListingSchema())
@use_kwargs(
{
"post_type": PostType(load_from="type", required=True),
"order_name": String(load_from="order", missing="new"),
"search": String(load_from="q", missing=""),
}
)
def get_user_search(
request: Request,
after: Optional[str],
before: Optional[str],
per_page: int,
anchor_type: Optional[str],
order_name: str,
post_type: Optional[str],
search: str,
) -> dict:
"""Generate the search results page for a user's posts."""
user = request.context
types_to_query: List[Union[Type[Topic], Type[Comment]]]
order_options: Union[Type[TopicSortOption], Type[CommentSortOption]]
if post_type == "topic":
types_to_query = [Topic]
order_options = TopicSortOption
elif post_type == "comment":
types_to_query = [Comment]
order_options = CommentSortOption
# try to get the specified order, but fall back to "newest"
order: Union[TopicSortOption, CommentSortOption]
try:
order = order_options[order_name.upper()]
except KeyError:
order = order_options["NEW"]
posts = _get_user_posts(
request=request,
user=user,
types_to_query=types_to_query,
anchor_type=anchor_type,
before=before,
after=after,
order=order,
per_page=per_page,
search=search,
)
return {
"user": user,
"search": search,
"posts": posts,
"post_type": post_type,
"order": order,
"order_options": order_options,
"comment_label_options": CommentLabelOption,
}
@view_config(route_name="invite", renderer="invite.jinja2") @view_config(route_name="invite", renderer="invite.jinja2")
def get_invite(request: Request) -> dict: def get_invite(request: Request) -> dict:
"""Generate the invite page.""" """Generate the invite page."""
@ -112,6 +176,7 @@ def _get_user_posts(
after: Optional[str], after: Optional[str],
order: Optional[Union[TopicSortOption, CommentSortOption]], order: Optional[Union[TopicSortOption, CommentSortOption]],
per_page: int, per_page: int,
search: Optional[str] = None,
) -> Union[PaginatedResults, MixedPaginatedResults]: ) -> Union[PaginatedResults, MixedPaginatedResults]:
"""Get the posts to display on a user page (topics, comments, or both).""" """Get the posts to display on a user page (topics, comments, or both)."""
result_sets = [] result_sets = []
@ -130,6 +195,9 @@ def _get_user_posts(
if order: if order:
query = query.apply_sort_option(order) query = query.apply_sort_option(order)
if search:
query = query.search(search)
query = query.join_all_relationships() query = query.join_all_relationships()
# include removed posts if the user's looking at their own page or is an admin # include removed posts if the user's looking at their own page or is an admin

Loading…
Cancel
Save