Browse Source

Merge branch 'master' into feature-group-404

merge-requests/28/head
Celeo 7 years ago
parent
commit
7933ca9e7f
  1. 63
      tildes/alembic/versions/50c251c4a19c_add_search_column_index_for_topics.py
  2. 1
      tildes/requirements-to-freeze.txt
  3. 1
      tildes/requirements.txt
  4. 2
      tildes/scss/modules/_btn.scss
  5. 8
      tildes/scss/modules/_sidebar.scss
  6. 1
      tildes/scss/modules/_site-header.scss
  7. 22
      tildes/scss/modules/_topic.scss
  8. 15
      tildes/sql/init/triggers/topics/topics.sql
  9. 11
      tildes/static/js/behaviors/comment-collapse-all-button.js
  10. 11
      tildes/static/js/behaviors/comment-expand-all-button.js
  11. 16
      tildes/tests/test_topic.py
  12. 35
      tildes/tildes/__init__.py
  13. 2
      tildes/tildes/lib/ratelimit.py
  14. 16
      tildes/tildes/models/topic/topic.py
  15. 5
      tildes/tildes/models/topic/topic_query.py
  16. 2
      tildes/tildes/routes.py
  17. 7
      tildes/tildes/templates/home.jinja2
  18. 31
      tildes/tildes/templates/search.jinja2
  19. 14
      tildes/tildes/templates/topic.jinja2
  20. 31
      tildes/tildes/templates/topic_listing.jinja2
  21. 4
      tildes/tildes/views/api/web/comment.py
  22. 6
      tildes/tildes/views/decorators.py
  23. 61
      tildes/tildes/views/topic.py

63
tildes/alembic/versions/50c251c4a19c_add_search_column_index_for_topics.py

@ -0,0 +1,63 @@
"""Add search column/index for topics
Revision ID: 50c251c4a19c
Revises: d33fb803a153
Create Date: 2018-08-20 19:18:04.129255
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "50c251c4a19c"
down_revision = "d33fb803a153"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"topics", sa.Column("search_tsv", postgresql.TSVECTOR(), nullable=True)
)
op.create_index(
"ix_topics_search_tsv_gin",
"topics",
["search_tsv"],
unique=False,
postgresql_using="gin",
)
op.execute(
"""
UPDATE topics
SET search_tsv = to_tsvector('pg_catalog.english', title)
|| to_tsvector('pg_catalog.english', COALESCE(markdown, ''));
"""
)
op.execute(
"""
CREATE TRIGGER topic_update_search_tsv_insert
BEFORE INSERT ON topics
FOR EACH ROW
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);
CREATE TRIGGER topic_update_search_tsv_update
BEFORE UPDATE ON topics
FOR EACH ROW
WHEN (
(OLD.title IS DISTINCT FROM NEW.title)
OR (OLD.markdown IS DISTINCT FROM NEW.markdown)
)
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);
"""
)
def downgrade():
op.drop_index("ix_topics_search_tsv_gin", table_name="topics")
op.drop_column("topics", "search_tsv")
op.execute("DROP TRIGGER topic_update_search_tsv_insert ON topics")
op.execute("DROP TRIGGER topic_update_search_tsv_update ON topics")

1
tildes/requirements-to-freeze.txt

@ -37,6 +37,7 @@ SQLAlchemy
SQLAlchemy-Utils SQLAlchemy-Utils
stripe stripe
testing.redis testing.redis
titlecase
webargs webargs
webtest webtest
zope.sqlalchemy zope.sqlalchemy

1
tildes/requirements.txt

@ -83,6 +83,7 @@ SQLAlchemy-Utils==0.33.3
stripe==2.4.0 stripe==2.4.0
testing.common.database==2.0.3 testing.common.database==2.0.3
testing.redis==1.1.1 testing.redis==1.1.1
titlecase==0.12.0
toml==0.9.4 toml==0.9.4
traitlets==4.3.2 traitlets==4.3.2
transaction==2.2.1 transaction==2.2.1

2
tildes/scss/modules/_btn.scss

@ -52,10 +52,8 @@
font-weight: normal; font-weight: normal;
border-left-width: 0;
margin-right: 0.4rem; margin-right: 0.4rem;
@media (min-width: $size-md) { @media (min-width: $size-md) {
border-left-width: 1px;
margin-right: 0.2rem; margin-right: 0.2rem;
min-width: 0.8rem; min-width: 0.8rem;
} }

8
tildes/scss/modules/_sidebar.scss

@ -11,6 +11,14 @@
.sidebar-controls .btn { .sidebar-controls .btn {
width: auto; width: auto;
} }
.form-search {
margin-bottom: 1rem;
.btn {
font-weight: normal;
}
}
} }
.sidebar-controls { .sidebar-controls {

1
tildes/scss/modules/_site-header.scss

@ -12,6 +12,7 @@
} }
.site-header-context { .site-header-context {
white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }

22
tildes/scss/modules/_topic.scss

@ -245,17 +245,21 @@
overflow: auto; overflow: auto;
} }
.topic-comments {
header {
display: flex;
.topic-comments-header {
display: flex;
flex-wrap: wrap;
align-items: center;
h2 {
white-space: nowrap;
}
margin-bottom: 0.4rem;
.form-listing-options {
margin-left: auto;
}
h2 {
margin-bottom: 0;
margin-right: 0.4rem;
white-space: nowrap;
}
.form-listing-options {
margin-left: auto;
} }
} }

15
tildes/sql/init/triggers/topics/topics.sql

@ -12,3 +12,18 @@ CREATE TRIGGER delete_topic_set_deleted_time_update
FOR EACH ROW FOR EACH ROW
WHEN (OLD.is_deleted = false AND NEW.is_deleted = true) WHEN (OLD.is_deleted = false AND NEW.is_deleted = true)
EXECUTE PROCEDURE set_topic_deleted_time(); EXECUTE PROCEDURE set_topic_deleted_time();
CREATE TRIGGER topic_update_search_tsv_insert
BEFORE INSERT ON topics
FOR EACH ROW
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);
CREATE TRIGGER topic_update_search_tsv_update
BEFORE UPDATE ON topics
FOR EACH ROW
WHEN (
(OLD.title IS DISTINCT FROM NEW.title)
OR (OLD.markdown IS DISTINCT FROM NEW.markdown)
)
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);

11
tildes/static/js/behaviors/comment-collapse-all-button.js

@ -0,0 +1,11 @@
$.onmount('[data-js-comment-collapse-all-button]', function() {
$(this).click(function(event) {
$('.comment[data-comment-depth="1"]:not(.is-comment-collapsed)').each(
function(idx, child) {
$(child).find(
'[data-js-comment-collapse-button]:first').trigger('click');
});
$(this).blur();
});
});

11
tildes/static/js/behaviors/comment-expand-all-button.js

@ -0,0 +1,11 @@
$.onmount('[data-js-comment-expand-all-button]', function() {
$(this).click(function(event) {
$('.comment.is-comment-collapsed').each(
function(idx, child) {
$(child).find(
'[data-js-comment-collapse-button]:first').trigger('click');
});
$(this).blur();
});
});

16
tildes/tests/test_topic.py

@ -130,3 +130,19 @@ def test_multiple_edits_update_time(text_topic):
def test_topic_initial_last_activity_time(text_topic): def test_topic_initial_last_activity_time(text_topic):
"""Ensure last_activity_time is initially the same as created_time.""" """Ensure last_activity_time is initially the same as created_time."""
assert text_topic.last_activity_time == text_topic.created_time assert text_topic.last_activity_time == text_topic.created_time
def test_convert_all_caps(session_user, session_group):
"""Ensure that all-caps titles are converted to title case."""
topic = Topic.create_link_topic(
session_group, session_user, "THE TITLE", "http://example.com"
)
assert topic.title == "The Title"
def test_no_convert_partial_caps_title(session_user, session_group):
"""Ensure that partially-caps titles are not converted to title case."""
topic = Topic.create_link_topic(
session_group, session_user, "This is a TITLE", "http://example.com"
)
assert topic.title == "This is a TITLE"

35
tildes/tildes/__init__.py

@ -1,9 +1,10 @@
"""Configure and initialize the Pyramid app.""" """Configure and initialize the Pyramid app."""
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, Optional, Tuple
from paste.deploy.config import PrefixMiddleware from paste.deploy.config import PrefixMiddleware
from pyramid.config import Configurator from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPTooManyRequests
from pyramid.registry import Registry from pyramid.registry import Registry
from pyramid.request import Request from pyramid.request import Request
from redis import StrictRedis from redis import StrictRedis
@ -50,6 +51,7 @@ def main(global_config: Dict[str, str], **settings: str) -> PrefixMiddleware:
# pylint: enable=unnecessary-lambda # pylint: enable=unnecessary-lambda
config.add_request_method(check_rate_limit, "check_rate_limit") config.add_request_method(check_rate_limit, "check_rate_limit")
config.add_request_method(apply_rate_limit, "apply_rate_limit")
config.add_request_method(current_listing_base_url, "current_listing_base_url") config.add_request_method(current_listing_base_url, "current_listing_base_url")
config.add_request_method(current_listing_normal_url, "current_listing_normal_url") config.add_request_method(current_listing_normal_url, "current_listing_normal_url")
@ -120,6 +122,13 @@ def check_rate_limit(request: Request, action_name: str) -> RateLimitResult:
return RateLimitResult.merged_result(results) return RateLimitResult.merged_result(results)
def apply_rate_limit(request: Request, action_name: str) -> None:
"""Check the rate limit for an action, and raise HTTP 429 if it's exceeded."""
result = request.check_rate_limit(action_name)
if not result.is_allowed:
raise result.add_headers_to_response(HTTPTooManyRequests())
def current_listing_base_url( def current_listing_base_url(
request: Request, query: Optional[Dict[str, Any]] = None request: Request, query: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
@ -130,10 +139,18 @@ def current_listing_base_url(
The `query` argument allows adding query variables to the generated url. The `query` argument allows adding query variables to the generated url.
""" """
if request.matched_route.name not in ("home", "group", "user"):
base_vars_by_route: Dict[str, Tuple[str, ...]] = {
"group": ("order", "period", "per_page", "tag", "unfiltered"),
"home": ("order", "period", "per_page", "tag", "unfiltered"),
"search": ("order", "period", "per_page", "q"),
"user": ("per_page", "type"),
}
try:
base_view_vars = base_vars_by_route[request.matched_route.name]
except KeyError:
raise AttributeError("Current route is not supported.") raise AttributeError("Current route is not supported.")
base_view_vars = ("order", "period", "per_page", "tag", "type", "unfiltered")
query_vars = { query_vars = {
key: val for key, val in request.GET.copy().items() if key in base_view_vars key: val for key, val in request.GET.copy().items() if key in base_view_vars
} }
@ -156,10 +173,18 @@ def current_listing_normal_url(
The `query` argument allows adding query variables to the generated url. The `query` argument allows adding query variables to the generated url.
""" """
if request.matched_route.name not in ("home", "group", "user"):
normal_vars_by_route: Dict[str, Tuple[str, ...]] = {
"group": ("order", "period", "per_page"),
"home": ("order", "period", "per_page"),
"search": ("order", "period", "per_page", "q"),
"user": ("per_page",),
}
try:
normal_view_vars = normal_vars_by_route[request.matched_route.name]
except KeyError:
raise AttributeError("Current route is not supported.") raise AttributeError("Current route is not supported.")
normal_view_vars = ("order", "period", "per_page")
query_vars = { query_vars = {
key: val for key, val in request.GET.copy().items() if key in normal_view_vars key: val for key, val in request.GET.copy().items() if key in normal_view_vars
} }

2
tildes/tildes/lib/ratelimit.py

@ -281,6 +281,8 @@ _RATE_LIMITED_ACTIONS = (
RateLimitedAction("login", timedelta(hours=1), 20), RateLimitedAction("login", timedelta(hours=1), 20),
RateLimitedAction("login_two_factor", timedelta(hours=1), 20), RateLimitedAction("login_two_factor", timedelta(hours=1), 20),
RateLimitedAction("register", timedelta(hours=1), 50), RateLimitedAction("register", timedelta(hours=1), 50),
RateLimitedAction("topic_post", timedelta(hours=1), 6, max_burst=4),
RateLimitedAction("comment_post", timedelta(hours=1), 30, max_burst=20),
) )
# (public) dict to be able to look up the actions by name # (public) dict to be able to look up the actions by name

16
tildes/tildes/models/topic/topic.py

@ -14,12 +14,13 @@ from sqlalchemy import (
Text, Text,
TIMESTAMP, TIMESTAMP,
) )
from sqlalchemy.dialects.postgresql import ENUM, JSONB
from sqlalchemy.dialects.postgresql import ENUM, JSONB, TSVECTOR
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import deferred, relationship from sqlalchemy.orm import deferred, relationship
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
from titlecase import titlecase
from tildes.enums import TopicType from tildes.enums import TopicType
from tildes.lib.database import ArrayOfLtree from tildes.lib.database import ArrayOfLtree
@ -111,12 +112,16 @@ class Topic(DatabaseModel):
) )
is_official: bool = Column(Boolean, nullable=False, server_default="false") is_official: bool = Column(Boolean, nullable=False, server_default="false")
is_locked: bool = Column(Boolean, nullable=False, server_default="false") is_locked: bool = Column(Boolean, nullable=False, server_default="false")
search_tsv: Any = deferred(Column(TSVECTOR))
user: User = relationship("User", lazy=False, innerjoin=True) user: User = relationship("User", lazy=False, innerjoin=True)
group: Group = relationship("Group", innerjoin=True) group: Group = relationship("Group", innerjoin=True)
# Create a GiST index on the tags column
__table_args__ = (Index("ix_topics_tags_gist", _tags, postgresql_using="gist"),)
# Create specialized indexes
__table_args__ = (
Index("ix_topics_tags_gist", _tags, postgresql_using="gist"),
Index("ix_topics_search_tsv_gin", "search_tsv", postgresql_using="gin"),
)
@hybrid_property @hybrid_property
def markdown(self) -> Optional[str]: def markdown(self) -> Optional[str]:
@ -168,6 +173,11 @@ class Topic(DatabaseModel):
new_topic = cls() new_topic = cls()
new_topic.group_id = group.group_id new_topic.group_id = group.group_id
new_topic.user_id = author.user_id new_topic.user_id = author.user_id
# if the title is all caps, convert to title case
if title.isupper():
title = titlecase(title)
new_topic.title = title new_topic.title = title
return new_topic return new_topic

5
tildes/tildes/models/topic/topic_query.py

@ -3,6 +3,7 @@
from typing import Any, Sequence from typing import Any, Sequence
from pyramid.request import Request from pyramid.request import Request
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, null from sqlalchemy.sql.expression import and_, null
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
@ -137,3 +138,7 @@ class TopicQuery(PaginatedQuery):
# pylint: disable=protected-access # pylint: disable=protected-access
return self.filter(Topic._tags.descendant_of(tag)) # type: ignore return self.filter(Topic._tags.descendant_of(tag)) # type: ignore
def search(self, query: str) -> "TopicQuery":
"""Restrict the topics to ones that match a search query (generative)."""
return self.filter(Topic.search_tsv.op("@@")(func.plainto_tsquery(query)))

2
tildes/tildes/routes.py

@ -17,6 +17,8 @@ def includeme(config: Configurator) -> None:
"""Set up application routes.""" """Set up application routes."""
config.add_route("home", "/") config.add_route("home", "/")
config.add_route("search", "/search")
config.add_route("groups", "/groups") config.add_route("groups", "/groups")
config.add_route("login", "/login") config.add_route("login", "/login")

7
tildes/tildes/templates/home.jinja2

@ -17,6 +17,13 @@
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}
<form class="form-search" method="GET" action="/search">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h2>Home</h2> <h2>Home</h2>
<p>The home page shows topics from groups that you're subscribed to.</p> <p>The home page shows topics from groups that you're subscribed to.</p>
{% if request.user %} {% if request.user %}

31
tildes/tildes/templates/search.jinja2

@ -0,0 +1,31 @@
{% extends 'topic_listing.jinja2' %}
{% block title %}Search results for "{{ search }}"{% endblock %}
{% block header_context_link %}<span class="site-header-context">Search: "{{ search }}"</span>{% endblock %}
{% block sidebar %}
<form class="form-search" method="GET" action="/search">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q" value="{{ search }}">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h2>Search results</h2>
<p><a href="/">Back to home page</a></p>
{% if request.user and request.user.subscriptions %}
<div class="divider"></div>
<ul class="nav">
<li>Subscriptions</li>
<ul class="nav">
{% for subscription in request.user.subscriptions|sort(attribute='group') %}
<li class="nav-item"><a href="/~{{ subscription.group.path }}">~{{ subscription.group.path }}</a></li>
{% endfor %}
</ul>
</ul>
{% endif %}
{% endblock %}

14
tildes/tildes/templates/topic.jinja2

@ -144,7 +144,7 @@
{% if topic.num_comments > 0 %} {% if topic.num_comments > 0 %}
<section class="topic-comments"> <section class="topic-comments">
<header>
<header class="topic-comments-header">
<h2> <h2>
{% trans num_comments=topic.num_comments %} {% trans num_comments=topic.num_comments %}
{{ num_comments }} comment {{ num_comments }} comment
@ -153,6 +153,9 @@
{% endtrans %} {% endtrans %}
</h2> </h2>
<button class="btn btn-comment-collapse" title="Collapse reply comments" data-js-comment-collapse-all-button>&minus;</button>
<button class="btn btn-comment-collapse" title="Expand all comments" data-js-comment-expand-all-button>+</button>
<form class="form-listing-options" method="get"> <form class="form-listing-options" method="get">
<div class="form-group"> <div class="form-group">
<label for="comment_order">Comments sorted by</label> <label for="comment_order">Comments sorted by</label>
@ -166,12 +169,11 @@
>{{ option.description }}</option> >{{ option.description }}</option>
{% endfor %} {% endfor %}
</select> </select>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</div> </div>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</form> </form>
</header> </header>

31
tildes/tildes/templates/topic_listing.jinja2

@ -46,9 +46,14 @@
<form class="form-listing-options" method="get"> <form class="form-listing-options" method="get">
<input type="hidden" name="order" value="{{ order.name.lower() }}"> <input type="hidden" name="order" value="{{ order.name.lower() }}">
{% if tag %}
{% if tag is defined and tag %}
<input type="hidden" name="tag" value="{{ tag }}"> <input type="hidden" name="tag" value="{{ tag }}">
{% endif %} {% endif %}
{% if search is defined %}
<input type="hidden" name="q" value="{{ search }}">
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="period">from</label> <label for="period">from</label>
<select id="period" name="period" class="form-select" data-js-time-period-select> <select id="period" name="period" class="form-select" data-js-time-period-select>
@ -64,15 +69,14 @@
<option value="all"{{ ' selected' if not period else '' }}>all time</option> <option value="all"{{ ' selected' if not period else '' }}>all time</option>
<option value="other">other period</option> <option value="other">other period</option>
</select> </select>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</div> </div>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</form> </form>
{% if not is_default_view %}
{% if is_default_view is defined and not is_default_view %}
<form <form
{% if is_single_group %} {% if is_single_group %}
data-ic-patch-to="{{ request.route_url( data-ic-patch-to="{{ request.route_url(
@ -96,6 +100,7 @@
{% endif %} {% endif %}
</div> </div>
{% if search is not defined %}
<div class="topic-listing-filter"> <div class="topic-listing-filter">
{% if tag %} {% if tag %}
Showing only topics with the tag "{{ tag|replace('_', ' ') }}". Showing only topics with the tag "{{ tag|replace('_', ' ') }}".
@ -108,6 +113,7 @@
<a href="{{ request.current_listing_normal_url({'unfiltered': 'true'}) }}">View unfiltered list</a> <a href="{{ request.current_listing_normal_url({'unfiltered': 'true'}) }}">View unfiltered list</a>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% if not topics %} {% if not topics %}
<div class="empty"> <div class="empty">
@ -125,7 +131,7 @@
{% if topics %} {% if topics %}
<ol class="topic-listing" <ol class="topic-listing"
{% if rank_start is not none %}
{% if rank_start is defined and rank_start is not none %}
start="{{ rank_start }}" start="{{ rank_start }}"
{% endif %} {% endif %}
> >
@ -134,7 +140,7 @@
<li> <li>
{# only display the rank on topics if the rank_start variable is set #} {# only display the rank on topics if the rank_start variable is set #}
{% if rank_start is not none %}
{% if rank_start is defined and rank_start is not none %}
{{ render_topic_for_listing( {{ render_topic_for_listing(
topic, topic,
show_group=topic.group != request.context, show_group=topic.group != request.context,
@ -169,6 +175,13 @@
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}
<form class="form-search" method="GET" action="/search">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h3>~{{ group.path }}</h3> <h3>~{{ group.path }}</h3>
{% if group.short_description %} {% if group.short_description %}

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

@ -16,7 +16,7 @@ from tildes.models.comment import Comment, CommentNotification, CommentTag, Comm
from tildes.models.topic import TopicVisit from tildes.models.topic import TopicVisit
from tildes.schemas.comment import CommentSchema, CommentTagSchema from tildes.schemas.comment import CommentSchema, CommentTagSchema
from tildes.views import IC_NOOP from tildes.views import IC_NOOP
from tildes.views.decorators import ic_view_config
from tildes.views.decorators import ic_view_config, rate_limit_view
def _increment_topic_comments_seen(request: Request, comment: Comment) -> None: def _increment_topic_comments_seen(request: Request, comment: Comment) -> None:
@ -57,6 +57,7 @@ def _increment_topic_comments_seen(request: Request, comment: Comment) -> None:
permission="comment", permission="comment",
) )
@use_kwargs(CommentSchema(only=("markdown",))) @use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_toplevel_comment(request: Request, markdown: str) -> dict: def post_toplevel_comment(request: Request, markdown: str) -> dict:
"""Post a new top-level comment on a topic with Intercooler.""" """Post a new top-level comment on a topic with Intercooler."""
topic = request.context topic = request.context
@ -90,6 +91,7 @@ def post_toplevel_comment(request: Request, markdown: str) -> dict:
permission="reply", permission="reply",
) )
@use_kwargs(CommentSchema(only=("markdown",))) @use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_comment_reply(request: Request, markdown: str) -> dict: def post_comment_reply(request: Request, markdown: str) -> dict:
"""Post a reply to a comment with Intercooler.""" """Post a reply to a comment with Intercooler."""
parent_comment = request.context parent_comment = request.context

6
tildes/tildes/views/decorators.py

@ -2,7 +2,7 @@
from typing import Any, Callable from typing import Any, Callable
from pyramid.httpexceptions import HTTPFound, HTTPTooManyRequests
from pyramid.httpexceptions import HTTPFound
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
@ -35,10 +35,8 @@ def rate_limit_view(action_name: str) -> Callable:
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
def wrapper(*args: Any, **kwargs: Any) -> Any: def wrapper(*args: Any, **kwargs: Any) -> Any:
request = args[0] request = args[0]
result = request.check_rate_limit(action_name)
if not result.is_allowed:
raise result.add_headers_to_response(HTTPTooManyRequests())
request.apply_rate_limit(action_name)
return func(*args, **kwargs) return func(*args, **kwargs)

61
tildes/tildes/views/topic.py

@ -30,6 +30,7 @@ from tildes.schemas.comment import CommentSchema
from tildes.schemas.fields import Enum, ShortTimePeriod from tildes.schemas.fields import Enum, ShortTimePeriod
from tildes.schemas.topic import TopicSchema from tildes.schemas.topic import TopicSchema
from tildes.schemas.topic_listing import TopicListingSchema from tildes.schemas.topic_listing import TopicListingSchema
from tildes.views.decorators import rate_limit_view
DefaultSettings = namedtuple("DefaultSettings", ["order", "period"]) DefaultSettings = namedtuple("DefaultSettings", ["order", "period"])
@ -64,6 +65,8 @@ def post_group_topics(
except ValidationError: except ValidationError:
raise ValidationError({"tags": ["Invalid tags"]}) raise ValidationError({"tags": ["Invalid tags"]})
request.apply_rate_limit("topic_post")
request.db_session.add(new_topic) request.db_session.add(new_topic)
request.db_session.add(LogTopic(LogEventType.TOPIC_POST, request, new_topic)) request.db_session.add(LogTopic(LogEventType.TOPIC_POST, request, new_topic))
@ -161,6 +164,63 @@ def get_group_topics(
} }
@view_config(route_name="search", renderer="search.jinja2")
@use_kwargs(TopicListingSchema(only=("after", "before", "order", "per_page", "period")))
@use_kwargs({"search": String(load_from="q")})
def get_search(
request: Request,
order: Any,
period: Any,
after: str,
before: str,
per_page: int,
search: str,
) -> dict:
"""Get a list of search results."""
# pylint: disable=too-many-arguments
if order is missing:
order = TopicSortOption.NEW
if period is missing:
period = None
query = (
request.query(Topic)
.join_all_relationships()
.search(search)
.apply_sort_option(order)
)
# 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": search,
"topics": topics,
"order": order,
"order_options": TopicSortOption,
"period": period,
"period_options": period_options,
}
@view_config( @view_config(
route_name="new_topic", renderer="new_topic.jinja2", permission="post_topic" route_name="new_topic", renderer="new_topic.jinja2", permission="post_topic"
) )
@ -225,6 +285,7 @@ def get_topic(request: Request, comment_order: CommentSortOption) -> dict:
@view_config(route_name="topic", request_method="POST", permission="comment") @view_config(route_name="topic", request_method="POST", permission="comment")
@use_kwargs(CommentSchema(only=("markdown",))) @use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound: def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound:
"""Post a new top-level comment on a topic.""" """Post a new top-level comment on a topic."""
topic = request.context topic = request.context

Loading…
Cancel
Save