Browse Source

Upgrade webargs to 6.1.0

This was not a fun upgrade. webargs made some major changes to its
approaches in 6.0, which are mostly covered here:
https://webargs.readthedocs.io/en/latest/upgrading.html

To keep using it on Tildes, this commit had to make the following
changes:

  - Write my own wrapper for use_kwargs that changes some of the default
    behavior. Specifically, we want the location that data is being
    loaded from to default to "query" (the query string) instead of
    webargs' default of "json". We also needed to set the "unknown"
    behavior on every schema to "exclude" so that the schemas would
    ignore any data fields they didn't need, since the default behavior
    is to throw an error, which happens almost everywhere because of
    Intercooler variables and/or multiple use_kwargs calls for different
    subsets of the data.

  - All @pre_load hooks in schemas needed to be rewritten so that they
    weren't modifying data in-place (copy to a new data dict first).
    Because webargs is now passing all data through all schemas,
    modifying in-place could result in an earlier schema modifying data
    that would then be passed in modified form to the later ones.
    Specifically, this caused an issue with tags on posting a new topic,
    where we just wanted to treat the tags as a string, but TopicSchema
    would convert it to a list in @pre_load.

  - use_kwargs on every endpoint using non-query data needed to be
    updated to support the new single-location approach, either replacing
    an existing locations= with location=, or adding location="form",
    since form data was no longer used by default.

  - The code that parsed the errors returned by webargs/Marshmallow
    ValidationErrors needed to update to handle the additional "level"
    in the dict of errors, where errors are now split out by location
    and then field, instead of only by field.

  - A few other minor updates, like always passing a schema object
    instead of a class, and never passing a callable (mostly just for
    simplicity in the wrapper).
merge-requests/126/merge
Deimos 4 years ago
parent
commit
f41bd1eabe
  1. 2
      tildes/requirements-dev.txt
  2. 2
      tildes/requirements.in
  3. 2
      tildes/requirements.txt
  4. 6
      tildes/tildes/resources/comment.py
  5. 6
      tildes/tildes/resources/group.py
  6. 4
      tildes/tildes/resources/message.py
  7. 4
      tildes/tildes/resources/topic.py
  8. 4
      tildes/tildes/resources/user.py
  9. 18
      tildes/tildes/schemas/group.py
  10. 20
      tildes/tildes/schemas/listing.py
  11. 40
      tildes/tildes/schemas/topic.py
  12. 35
      tildes/tildes/schemas/user.py
  13. 18
      tildes/tildes/views/api/web/comment.py
  14. 5
      tildes/tildes/views/api/web/group.py
  15. 5
      tildes/tildes/views/api/web/markdown_preview.py
  16. 5
      tildes/tildes/views/api/web/message.py
  17. 15
      tildes/tildes/views/api/web/topic.py
  18. 20
      tildes/tildes/views/api/web/user.py
  19. 4
      tildes/tildes/views/bookmarks.py
  20. 30
      tildes/tildes/views/decorators.py
  21. 6
      tildes/tildes/views/donate.py
  22. 6
      tildes/tildes/views/exceptions.py
  23. 8
      tildes/tildes/views/group_wiki_page.py
  24. 4
      tildes/tildes/views/ignored_topics.py
  25. 12
      tildes/tildes/views/login.py
  26. 6
      tildes/tildes/views/message.py
  27. 2
      tildes/tildes/views/notifications.py
  28. 26
      tildes/tildes/views/register.py
  29. 5
      tildes/tildes/views/settings.py
  30. 9
      tildes/tildes/views/topic.py
  31. 2
      tildes/tildes/views/user.py
  32. 4
      tildes/tildes/views/votes.py

2
tildes/requirements-dev.txt

@ -109,7 +109,7 @@ urllib3==1.25.10 # via requests, sentry-sdk
venusian==3.0.0 # via cornice, pyramid venusian==3.0.0 # via cornice, pyramid
waitress==1.4.4 # via webtest waitress==1.4.4 # via webtest
wcwidth==0.2.5 # via prompt-toolkit wcwidth==0.2.5 # via prompt-toolkit
webargs==5.5.3
webargs==6.1.0
webassets==2.0 # via pyramid-webassets webassets==2.0 # via pyramid-webassets
webencodings==0.5.1 # via bleach, html5lib webencodings==0.5.1 # via bleach, html5lib
webob==1.8.6 # via pyramid, webtest webob==1.8.6 # via pyramid, webtest

2
tildes/requirements.in

@ -34,6 +34,6 @@ SQLAlchemy
SQLAlchemy-Utils SQLAlchemy-Utils
stripe stripe
titlecase titlecase
webargs==5.5.3
webargs
wrapt wrapt
zope.sqlalchemy zope.sqlalchemy

2
tildes/requirements.txt

@ -67,7 +67,7 @@ translationstring==1.4 # via pyramid
urllib3==1.25.10 # via requests, sentry-sdk urllib3==1.25.10 # via requests, sentry-sdk
venusian==3.0.0 # via cornice, pyramid venusian==3.0.0 # via cornice, pyramid
wcwidth==0.2.5 # via prompt-toolkit wcwidth==0.2.5 # via prompt-toolkit
webargs==5.5.3
webargs==6.1.0
webassets==2.0 # via pyramid-webassets webassets==2.0 # via pyramid-webassets
webencodings==0.5.1 # via bleach, html5lib webencodings==0.5.1 # via bleach, html5lib
webob==1.8.6 # via pyramid webob==1.8.6 # via pyramid

6
tildes/tildes/resources/comment.py

@ -5,15 +5,15 @@
from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound
from pyramid.request import Request from pyramid.request import Request
from webargs.pyramidparser import use_kwargs
from tildes.lib.id import id36_to_id from tildes.lib.id import id36_to_id
from tildes.models.comment import Comment, CommentNotification from tildes.models.comment import Comment, CommentNotification
from tildes.resources import get_resource from tildes.resources import get_resource
from tildes.schemas.comment import CommentSchema from tildes.schemas.comment import CommentSchema
from tildes.views.decorators import use_kwargs
@use_kwargs(CommentSchema(only=("comment_id36",)), locations=("matchdict",))
@use_kwargs(CommentSchema(only=("comment_id36",)), location="matchdict")
def comment_by_id36(request: Request, comment_id36: str) -> Comment: def comment_by_id36(request: Request, comment_id36: str) -> Comment:
"""Get a comment specified by {comment_id36} in the route (or 404).""" """Get a comment specified by {comment_id36} in the route (or 404)."""
query = ( query = (
@ -28,7 +28,7 @@ def comment_by_id36(request: Request, comment_id36: str) -> Comment:
raise HTTPNotFound("Comment not found (or it was deleted)") raise HTTPNotFound("Comment not found (or it was deleted)")
@use_kwargs(CommentSchema(only=("comment_id36",)), locations=("matchdict",))
@use_kwargs(CommentSchema(only=("comment_id36",)), location="matchdict")
def notification_by_comment_id36( def notification_by_comment_id36(
request: Request, comment_id36: str request: Request, comment_id36: str
) -> CommentNotification: ) -> CommentNotification:

6
tildes/tildes/resources/group.py

@ -7,16 +7,16 @@ from marshmallow.fields import String
from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
from pyramid.request import Request from pyramid.request import Request
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
from webargs.pyramidparser import use_kwargs
from tildes.models.group import Group, GroupWikiPage from tildes.models.group import Group, GroupWikiPage
from tildes.resources import get_resource from tildes.resources import get_resource
from tildes.schemas.group import GroupSchema from tildes.schemas.group import GroupSchema
from tildes.views.decorators import use_kwargs
@use_kwargs( @use_kwargs(
GroupSchema(only=("path",), context={"fix_path_capitalization": True}), GroupSchema(only=("path",), context={"fix_path_capitalization": True}),
locations=("matchdict",),
location="matchdict",
) )
def group_by_path(request: Request, path: str) -> Group: def group_by_path(request: Request, path: str) -> Group:
"""Get a group specified by {path} in the route (or 404).""" """Get a group specified by {path} in the route (or 404)."""
@ -35,7 +35,7 @@ def group_by_path(request: Request, path: str) -> Group:
return get_resource(request, query) return get_resource(request, query)
@use_kwargs({"wiki_page_path": String()}, locations=("matchdict",))
@use_kwargs({"wiki_page_path": String()}, location="matchdict")
def group_wiki_page_by_path(request: Request, wiki_page_path: str) -> GroupWikiPage: def group_wiki_page_by_path(request: Request, wiki_page_path: str) -> GroupWikiPage:
"""Get a group's wiki page by its path (or 404).""" """Get a group's wiki page by its path (or 404)."""
group = group_by_path(request) # pylint: disable=no-value-for-parameter group = group_by_path(request) # pylint: disable=no-value-for-parameter

4
tildes/tildes/resources/message.py

@ -4,16 +4,16 @@
"""Root factories for messages.""" """Root factories for messages."""
from pyramid.request import Request from pyramid.request import Request
from webargs.pyramidparser import use_kwargs
from tildes.lib.id import id36_to_id from tildes.lib.id import id36_to_id
from tildes.models.message import MessageConversation from tildes.models.message import MessageConversation
from tildes.resources import get_resource from tildes.resources import get_resource
from tildes.schemas.message import MessageConversationSchema from tildes.schemas.message import MessageConversationSchema
from tildes.views.decorators import use_kwargs
@use_kwargs( @use_kwargs(
MessageConversationSchema(only=("conversation_id36",)), locations=("matchdict",)
MessageConversationSchema(only=("conversation_id36",)), location="matchdict"
) )
def message_conversation_by_id36( def message_conversation_by_id36(
request: Request, conversation_id36: str request: Request, conversation_id36: str

4
tildes/tildes/resources/topic.py

@ -5,15 +5,15 @@
from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.request import Request from pyramid.request import Request
from webargs.pyramidparser import use_kwargs
from tildes.lib.id import id36_to_id from tildes.lib.id import id36_to_id
from tildes.models.topic import Topic from tildes.models.topic import Topic
from tildes.resources import get_resource from tildes.resources import get_resource
from tildes.schemas.topic import TopicSchema from tildes.schemas.topic import TopicSchema
from tildes.views.decorators import use_kwargs
@use_kwargs(TopicSchema(only=("topic_id36",)), locations=("matchdict",))
@use_kwargs(TopicSchema(only=("topic_id36",)), location="matchdict")
def topic_by_id36(request: Request, topic_id36: str) -> Topic: def topic_by_id36(request: Request, topic_id36: str) -> Topic:
"""Get a topic specified by {topic_id36} in the route (or 404).""" """Get a topic specified by {topic_id36} in the route (or 404)."""
try: try:

4
tildes/tildes/resources/user.py

@ -4,14 +4,14 @@
"""Root factories for users.""" """Root factories for users."""
from pyramid.request import Request from pyramid.request import Request
from webargs.pyramidparser import use_kwargs
from tildes.models.user import User from tildes.models.user import User
from tildes.resources import get_resource from tildes.resources import get_resource
from tildes.schemas.user import UserSchema from tildes.schemas.user import UserSchema
from tildes.views.decorators import use_kwargs
@use_kwargs(UserSchema(only=("username",)), locations=("matchdict",))
@use_kwargs(UserSchema(only=("username",)), location="matchdict")
def user_by_username(request: Request, username: str) -> User: def user_by_username(request: Request, username: str) -> User:
"""Get a user specified by {username} in the route or 404 if not found.""" """Get a user specified by {username} in the route or 404 if not found."""
query = request.query(User).include_deleted().filter(User.username == username) query = request.query(User).include_deleted().filter(User.username == username)

18
tildes/tildes/schemas/group.py

@ -47,11 +47,15 @@ class GroupSchema(Schema):
if not self.context.get("fix_path_capitalization"): if not self.context.get("fix_path_capitalization"):
return data return data
if "path" in data and isinstance(data["path"], str):
data["path"] = data["path"].lower()
if "path" not in data or not isinstance(data["path"], str):
return data return data
new_data = data.copy()
new_data["path"] = new_data["path"].lower()
return new_data
@validates("path") @validates("path")
def validate_path(self, value: sqlalchemy_utils.Ltree) -> None: def validate_path(self, value: sqlalchemy_utils.Ltree) -> None:
"""Validate the path field, raising an error if an issue exists.""" """Validate the path field, raising an error if an issue exists."""
@ -71,11 +75,13 @@ class GroupSchema(Schema):
if "sidebar_markdown" not in data: if "sidebar_markdown" not in data:
return data return data
new_data = data.copy()
# if the value is empty, convert it to None # if the value is empty, convert it to None
if not data["sidebar_markdown"] or data["sidebar_markdown"].isspace():
data["sidebar_markdown"] = None
if not new_data["sidebar_markdown"] or new_data["sidebar_markdown"].isspace():
new_data["sidebar_markdown"] = None
return data
return new_data
def is_valid_group_path(path: str) -> bool: def is_valid_group_path(path: str) -> bool:

20
tildes/tildes/schemas/listing.py

@ -46,10 +46,12 @@ class TopicListingSchema(PaginatedListingSchema):
if "rank_start" not in self.fields: if "rank_start" not in self.fields:
return data return data
if not (data.get("before") or data.get("after")):
data["n"] = 1
new_data = data.copy()
return data
if not (new_data.get("before") or new_data.get("after")):
new_data["n"] = 1
return new_data
class MixedListingSchema(PaginatedListingSchema): class MixedListingSchema(PaginatedListingSchema):
@ -71,10 +73,12 @@ class MixedListingSchema(PaginatedListingSchema):
to the topic with ID36 "123". "c-123" also works, for comments. to the topic with ID36 "123". "c-123" also works, for comments.
""" """
# pylint: disable=unused-argument # pylint: disable=unused-argument
new_data = data.copy()
keys = ("after", "before") keys = ("after", "before")
for key in keys: for key in keys:
value = data.get(key)
value = new_data.get(key)
if not value: if not value:
continue continue
@ -83,12 +87,12 @@ class MixedListingSchema(PaginatedListingSchema):
continue continue
if type_char == "c": if type_char == "c":
data["anchor_type"] = "comment"
new_data["anchor_type"] = "comment"
elif type_char == "t": elif type_char == "t":
data["anchor_type"] = "topic"
new_data["anchor_type"] = "topic"
else: else:
continue continue
data[key] = id36
new_data[key] = id36
return data
return new_data

40
tildes/tildes/schemas/topic.py

@ -43,10 +43,12 @@ class TopicSchema(Schema):
if "title" not in data: if "title" not in data:
return data return data
new_data = data.copy()
# strip any trailing periods # strip any trailing periods
data["title"] = data["title"].rstrip(".")
new_data["title"] = new_data["title"].rstrip(".")
return data
return new_data
@pre_load @pre_load
def prepare_tags(self, data: dict, many: bool, partial: Any) -> dict: def prepare_tags(self, data: dict, many: bool, partial: Any) -> dict:
@ -55,9 +57,11 @@ class TopicSchema(Schema):
if "tags" not in data: if "tags" not in data:
return data return data
new_data = data.copy()
tags: typing.List[str] = [] tags: typing.List[str] = []
for tag in data["tags"]:
for tag in new_data["tags"]:
tag = tag.lower() tag = tag.lower()
# replace underscores with spaces # replace underscores with spaces
@ -84,9 +88,9 @@ class TopicSchema(Schema):
tags.append(tag) tags.append(tag)
data["tags"] = tags
new_data["tags"] = tags
return data
return new_data
@validates("tags") @validates("tags")
def validate_tags(self, value: typing.List[str]) -> None: def validate_tags(self, value: typing.List[str]) -> None:
@ -112,11 +116,13 @@ class TopicSchema(Schema):
if "markdown" not in data: if "markdown" not in data:
return data return data
new_data = data.copy()
# if the value is empty, convert it to None # if the value is empty, convert it to None
if not data["markdown"] or data["markdown"].isspace():
data["markdown"] = None
if not new_data["markdown"] or new_data["markdown"].isspace():
new_data["markdown"] = None
return data
return new_data
@pre_load @pre_load
def prepare_link(self, data: dict, many: bool, partial: Any) -> dict: def prepare_link(self, data: dict, many: bool, partial: Any) -> dict:
@ -125,23 +131,25 @@ class TopicSchema(Schema):
if "link" not in data: if "link" not in data:
return data return data
new_data = data.copy()
# remove leading/trailing whitespace # remove leading/trailing whitespace
data["link"] = data["link"].strip()
new_data["link"] = new_data["link"].strip()
# if the value is empty, convert it to None # if the value is empty, convert it to None
if not data["link"]:
data["link"] = None
return data
if not new_data["link"]:
new_data["link"] = None
return new_data
# prepend http:// to the link if it doesn't have a scheme # prepend http:// to the link if it doesn't have a scheme
parsed = urlparse(data["link"])
parsed = urlparse(new_data["link"])
if not parsed.scheme: if not parsed.scheme:
data["link"] = "http://" + data["link"]
new_data["link"] = "http://" + new_data["link"]
# run the link through the url-transformation process # run the link through the url-transformation process
data["link"] = apply_url_transformations(data["link"])
new_data["link"] = apply_url_transformations(new_data["link"])
return data
return new_data
@validates_schema @validates_schema
def link_or_markdown(self, data: dict, many: bool, partial: Any) -> None: def link_or_markdown(self, data: dict, many: bool, partial: Any) -> None:

35
tildes/tildes/schemas/user.py

@ -63,11 +63,18 @@ class UserSchema(Schema):
def anonymize_username(self, data: dict, many: bool) -> dict: def anonymize_username(self, data: dict, many: bool) -> dict:
"""Hide the username if the dumping context specifies to do so.""" """Hide the username if the dumping context specifies to do so."""
# pylint: disable=unused-argument # pylint: disable=unused-argument
if "username" in data and self.context.get("hide_username"):
data["username"] = "<unknown>"
if not self.context.get("hide_username"):
return data
if "username" not in data:
return data return data
new_data = data.copy()
new_data["username"] = "<unknown>"
return new_data
@validates_schema @validates_schema
def username_pass_not_substrings( def username_pass_not_substrings(
self, data: dict, many: bool, partial: Any self, data: dict, many: bool, partial: Any
@ -116,9 +123,11 @@ class UserSchema(Schema):
if "username" not in data: if "username" not in data:
return data return data
data["username"] = data["username"].strip()
new_data = data.copy()
return data
new_data["username"] = new_data["username"].strip()
return new_data
@pre_load @pre_load
def prepare_email_address(self, data: dict, many: bool, partial: Any) -> dict: def prepare_email_address(self, data: dict, many: bool, partial: Any) -> dict:
@ -127,14 +136,16 @@ class UserSchema(Schema):
if "email_address" not in data: if "email_address" not in data:
return data return data
new_data = data.copy()
# remove any leading/trailing whitespace # remove any leading/trailing whitespace
data["email_address"] = data["email_address"].strip()
new_data["email_address"] = new_data["email_address"].strip()
# if the value is empty, convert it to None # if the value is empty, convert it to None
if not data["email_address"] or data["email_address"].isspace():
data["email_address"] = None
if not new_data["email_address"] or new_data["email_address"].isspace():
new_data["email_address"] = None
return data
return new_data
@pre_load @pre_load
def prepare_bio_markdown(self, data: dict, many: bool, partial: Any) -> dict: def prepare_bio_markdown(self, data: dict, many: bool, partial: Any) -> dict:
@ -143,11 +154,13 @@ class UserSchema(Schema):
if "bio_markdown" not in data: if "bio_markdown" not in data:
return data return data
new_data = data.copy()
# if the value is empty, convert it to None # if the value is empty, convert it to None
if not data["bio_markdown"] or data["bio_markdown"].isspace():
data["bio_markdown"] = None
if not new_data["bio_markdown"] or new_data["bio_markdown"].isspace():
new_data["bio_markdown"] = None
return data
return new_data
def is_valid_username(username: str) -> bool: def is_valid_username(username: str) -> bool:

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

@ -9,7 +9,6 @@ from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import FlushError from sqlalchemy.orm.exc import FlushError
from webargs.pyramidparser import use_kwargs
from tildes.enums import CommentLabelOption, CommentNotificationType, LogEventType from tildes.enums import CommentLabelOption, CommentNotificationType, LogEventType
from tildes.models.comment import ( from tildes.models.comment import (
@ -22,7 +21,7 @@ from tildes.models.comment import (
from tildes.models.log import LogComment from tildes.models.log import LogComment
from tildes.schemas.comment import CommentLabelSchema, CommentSchema from tildes.schemas.comment import CommentLabelSchema, CommentSchema
from tildes.views import IC_NOOP from tildes.views import IC_NOOP
from tildes.views.decorators import ic_view_config, rate_limit_view
from tildes.views.decorators import ic_view_config, rate_limit_view, use_kwargs
def _mark_comment_read_from_interaction(request: Request, comment: Comment) -> None: def _mark_comment_read_from_interaction(request: Request, comment: Comment) -> None:
@ -47,7 +46,7 @@ def _mark_comment_read_from_interaction(request: Request, comment: Comment) -> N
renderer="single_comment.jinja2", renderer="single_comment.jinja2",
permission="comment", permission="comment",
) )
@use_kwargs(CommentSchema(only=("markdown",)))
@use_kwargs(CommentSchema(only=("markdown",)), location="form")
@rate_limit_view("comment_post") @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."""
@ -83,7 +82,7 @@ def post_toplevel_comment(request: Request, markdown: str) -> dict:
renderer="single_comment.jinja2", renderer="single_comment.jinja2",
permission="reply", permission="reply",
) )
@use_kwargs(CommentSchema(only=("markdown",)))
@use_kwargs(CommentSchema(only=("markdown",)), location="form")
@rate_limit_view("comment_post") @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."""
@ -159,7 +158,7 @@ def get_comment_edit(request: Request) -> dict:
renderer="comment_contents.jinja2", renderer="comment_contents.jinja2",
permission="edit", permission="edit",
) )
@use_kwargs(CommentSchema(only=("markdown",)))
@use_kwargs(CommentSchema(only=("markdown",)), location="form")
def patch_comment(request: Request, markdown: str) -> dict: def patch_comment(request: Request, markdown: str) -> dict:
"""Update a comment with Intercooler.""" """Update a comment with Intercooler."""
comment = request.context comment = request.context
@ -260,9 +259,8 @@ def delete_vote_comment(request: Request) -> dict:
permission="label", permission="label",
renderer="comment_contents.jinja2", renderer="comment_contents.jinja2",
) )
@use_kwargs(CommentLabelSchema(only=("name",)), locations=("matchdict",))
# need to specify only "form" location for reason, or it will crash by looking for JSON
@use_kwargs(CommentLabelSchema(only=("reason",)), locations=("form",))
@use_kwargs(CommentLabelSchema(only=("name",)), location="matchdict")
@use_kwargs(CommentLabelSchema(only=("reason",)), location="form")
def put_label_comment( def put_label_comment(
request: Request, name: CommentLabelOption, reason: str request: Request, name: CommentLabelOption, reason: str
) -> Response: ) -> Response:
@ -308,7 +306,7 @@ def put_label_comment(
permission="label", permission="label",
renderer="comment_contents.jinja2", renderer="comment_contents.jinja2",
) )
@use_kwargs(CommentLabelSchema(only=("name",)), locations=("matchdict",))
@use_kwargs(CommentLabelSchema(only=("name",)), location="matchdict")
def delete_label_comment(request: Request, name: CommentLabelOption) -> Response: def delete_label_comment(request: Request, name: CommentLabelOption) -> Response:
"""Remove a label (that the user previously added) from a comment.""" """Remove a label (that the user previously added) from a comment."""
comment = request.context comment = request.context
@ -337,7 +335,7 @@ def delete_label_comment(request: Request, name: CommentLabelOption) -> Response
@ic_view_config( @ic_view_config(
route_name="comment_mark_read", request_method="PUT", permission="mark_read" route_name="comment_mark_read", request_method="PUT", permission="mark_read"
) )
@use_kwargs({"mark_all_previous": Boolean(missing=False)}, locations=("query",))
@use_kwargs({"mark_all_previous": Boolean(missing=False)}, location="query")
def put_mark_comments_read(request: Request, mark_all_previous: bool) -> Response: def put_mark_comments_read(request: Request, mark_all_previous: bool) -> Response:
"""Mark comment(s) read, clearing notifications. """Mark comment(s) read, clearing notifications.

5
tildes/tildes/views/api/web/group.py

@ -8,7 +8,6 @@ from typing import Optional
from pyramid.request import Request from pyramid.request import Request
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from webargs.pyramidparser import use_kwargs
from zope.sqlalchemy import mark_changed from zope.sqlalchemy import mark_changed
from tildes.enums import TopicSortOption from tildes.enums import TopicSortOption
@ -17,7 +16,7 @@ from tildes.models.group import Group, GroupSubscription
from tildes.models.user import UserGroupSettings from tildes.models.user import UserGroupSettings
from tildes.schemas.fields import Enum, ShortTimePeriod from tildes.schemas.fields import Enum, ShortTimePeriod
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, use_kwargs
@ic_view_config( @ic_view_config(
@ -89,7 +88,7 @@ def delete_subscribe_group(request: Request) -> dict:
"order": Enum(TopicSortOption), "order": Enum(TopicSortOption),
"period": ShortTimePeriod(allow_none=True, missing=None), "period": ShortTimePeriod(allow_none=True, missing=None),
}, },
locations=("form",), # will crash due to trying to find JSON data without this
location="form",
) )
def patch_group_user_settings( def patch_group_user_settings(
request: Request, order: TopicSortOption, period: Optional[SimpleHoursPeriod] request: Request, order: TopicSortOption, period: Optional[SimpleHoursPeriod]

5
tildes/tildes/views/api/web/markdown_preview.py

@ -4,11 +4,10 @@
"""Web API endpoint for previewing Markdown.""" """Web API endpoint for previewing Markdown."""
from pyramid.request import Request from pyramid.request import Request
from webargs.pyramidparser import use_kwargs
from tildes.lib.markdown import convert_markdown_to_safe_html from tildes.lib.markdown import convert_markdown_to_safe_html
from tildes.schemas.group_wiki_page import GroupWikiPageSchema from tildes.schemas.group_wiki_page import GroupWikiPageSchema
from tildes.views.decorators import ic_view_config
from tildes.views.decorators import ic_view_config, use_kwargs
@ic_view_config( @ic_view_config(
@ -17,7 +16,7 @@ from tildes.views.decorators import ic_view_config
renderer="markdown_preview.jinja2", renderer="markdown_preview.jinja2",
) )
# uses GroupWikiPageSchema because it should always have the highest max_length # uses GroupWikiPageSchema because it should always have the highest max_length
@use_kwargs(GroupWikiPageSchema(only=("markdown",)))
@use_kwargs(GroupWikiPageSchema(only=("markdown",)), location="form")
def markdown_preview(request: Request, markdown: str) -> dict: def markdown_preview(request: Request, markdown: str) -> dict:
"""Render the provided text as Markdown.""" """Render the provided text as Markdown."""
# pylint: disable=unused-argument # pylint: disable=unused-argument

5
tildes/tildes/views/api/web/message.py

@ -4,11 +4,10 @@
"""Web API endpoints related to messages.""" """Web API endpoints related to messages."""
from pyramid.request import Request from pyramid.request import Request
from webargs.pyramidparser import use_kwargs
from tildes.models.message import MessageReply from tildes.models.message import MessageReply
from tildes.schemas.message import MessageReplySchema from tildes.schemas.message import MessageReplySchema
from tildes.views.decorators import ic_view_config
from tildes.views.decorators import ic_view_config, use_kwargs
@ic_view_config( @ic_view_config(
@ -17,7 +16,7 @@ from tildes.views.decorators import ic_view_config
renderer="single_message.jinja2", renderer="single_message.jinja2",
permission="reply", permission="reply",
) )
@use_kwargs(MessageReplySchema(only=("markdown",)))
@use_kwargs(MessageReplySchema(only=("markdown",)), location="form")
def post_message_reply(request: Request, markdown: str) -> dict: def post_message_reply(request: Request, markdown: str) -> dict:
"""Post a reply to a message conversation with Intercooler.""" """Post a reply to a message conversation with Intercooler."""
conversation = request.context conversation = request.context

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

@ -9,7 +9,6 @@ from pyramid.httpexceptions import HTTPNotFound
from pyramid.request import Request from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
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
@ -18,7 +17,7 @@ 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
from tildes.views.decorators import ic_view_config
from tildes.views.decorators import ic_view_config, use_kwargs
@ic_view_config( @ic_view_config(
@ -50,7 +49,7 @@ def get_topic_contents(request: Request) -> dict:
renderer="topic_contents.jinja2", renderer="topic_contents.jinja2",
permission="edit", permission="edit",
) )
@use_kwargs(TopicSchema(only=("markdown",)))
@use_kwargs(TopicSchema(only=("markdown",)), location="form")
def patch_topic(request: Request, markdown: str) -> dict: def patch_topic(request: Request, markdown: str) -> dict:
"""Update a topic with Intercooler.""" """Update a topic with Intercooler."""
topic = request.context topic = request.context
@ -158,7 +157,9 @@ def get_topic_tags(request: Request) -> dict:
renderer="topic_tags.jinja2", renderer="topic_tags.jinja2",
permission="tag", permission="tag",
) )
@use_kwargs({"tags": String(missing=""), "conflict_check": String(missing="")})
@use_kwargs(
{"tags": String(missing=""), "conflict_check": String(missing="")}, location="form"
)
def put_tag_topic(request: Request, tags: str, conflict_check: str) -> dict: def put_tag_topic(request: Request, tags: str, conflict_check: str) -> dict:
"""Apply tags to a topic with Intercooler.""" """Apply tags to a topic with Intercooler."""
topic = request.context topic = request.context
@ -225,7 +226,7 @@ def get_topic_group(request: Request) -> dict:
request_method="PATCH", request_method="PATCH",
permission="move", permission="move",
) )
@use_kwargs(GroupSchema(only=("path",)))
@use_kwargs(GroupSchema(only=("path",)), location="form")
def patch_move_topic(request: Request, path: str) -> dict: def patch_move_topic(request: Request, path: str) -> dict:
"""Move a topic to a different group with Intercooler.""" """Move a topic to a different group with Intercooler."""
topic = request.context topic = request.context
@ -334,7 +335,7 @@ def get_topic_title(request: Request) -> dict:
request_method="PATCH", request_method="PATCH",
permission="edit_title", permission="edit_title",
) )
@use_kwargs(TopicSchema(only=("title",)))
@use_kwargs(TopicSchema(only=("title",)), location="form")
def patch_topic_title(request: Request, title: str) -> dict: def patch_topic_title(request: Request, title: str) -> dict:
"""Edit a topic's title with Intercooler.""" """Edit a topic's title with Intercooler."""
topic = request.context topic = request.context
@ -373,7 +374,7 @@ def get_topic_link(request: Request) -> dict:
request_method="PATCH", request_method="PATCH",
permission="edit_link", permission="edit_link",
) )
@use_kwargs(TopicSchema(only=("link",)))
@use_kwargs(TopicSchema(only=("link",)), location="form")
def patch_topic_link(request: Request, link: str) -> dict: def patch_topic_link(request: Request, link: str) -> dict:
"""Edit a topic's link with Intercooler.""" """Edit a topic's link with Intercooler."""
topic = request.context topic = request.context

20
tildes/tildes/views/api/web/user.py

@ -17,7 +17,6 @@ from pyramid.httpexceptions import (
from pyramid.request import Request from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType, TopicSortOption from tildes.enums import LogEventType, TopicSortOption
from tildes.lib.datetime import SimpleHoursPeriod from tildes.lib.datetime import SimpleHoursPeriod
@ -28,7 +27,7 @@ from tildes.schemas.fields import Enum, ShortTimePeriod
from tildes.schemas.topic import TopicSchema from tildes.schemas.topic import TopicSchema
from tildes.schemas.user import UserSchema from tildes.schemas.user import UserSchema
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, use_kwargs
PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"] PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"]
@ -45,7 +44,8 @@ PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"]
"old_password": PASSWORD_FIELD, "old_password": PASSWORD_FIELD,
"new_password": PASSWORD_FIELD, "new_password": PASSWORD_FIELD,
"new_password_confirm": PASSWORD_FIELD, "new_password_confirm": PASSWORD_FIELD,
}
},
location="form",
) )
def patch_change_password( def patch_change_password(
request: Request, old_password: str, new_password: str, new_password_confirm: str request: Request, old_password: str, new_password: str, new_password_confirm: str
@ -70,7 +70,7 @@ def patch_change_password(
request_param="ic-trigger-name=account-recovery-email", request_param="ic-trigger-name=account-recovery-email",
permission="change_settings", permission="change_settings",
) )
@use_kwargs(UserSchema(only=("email_address", "email_address_note")))
@use_kwargs(UserSchema(only=("email_address", "email_address_note")), location="form")
def patch_change_email_address( def patch_change_email_address(
request: Request, email_address: str, email_address_note: str request: Request, email_address: str, email_address_note: str
) -> Response: ) -> Response:
@ -102,7 +102,7 @@ def patch_change_email_address(
renderer="two_factor_enabled.jinja2", renderer="two_factor_enabled.jinja2",
permission="change_settings", permission="change_settings",
) )
@use_kwargs({"code": String()})
@use_kwargs({"code": String()}, location="form")
def post_enable_two_factor(request: Request, code: str) -> dict: def post_enable_two_factor(request: Request, code: str) -> dict:
"""Enable two-factor authentication for the user.""" """Enable two-factor authentication for the user."""
user = request.context user = request.context
@ -132,7 +132,7 @@ def post_enable_two_factor(request: Request, code: str) -> dict:
renderer="two_factor_disabled.jinja2", renderer="two_factor_disabled.jinja2",
permission="change_settings", permission="change_settings",
) )
@use_kwargs({"code": String()})
@use_kwargs({"code": String()}, location="form")
def post_disable_two_factor(request: Request, code: str) -> Response: def post_disable_two_factor(request: Request, code: str) -> Response:
"""Disable two-factor authentication for the user.""" """Disable two-factor authentication for the user."""
if not request.user.is_correct_two_factor_code(code): if not request.user.is_correct_two_factor_code(code):
@ -152,7 +152,7 @@ def post_disable_two_factor(request: Request, code: str) -> Response:
renderer="two_factor_backup_codes.jinja2", renderer="two_factor_backup_codes.jinja2",
permission="change_settings", permission="change_settings",
) )
@use_kwargs({"code": String()})
@use_kwargs({"code": String()}, location="form")
def post_view_two_factor_backup_codes(request: Request, code: str) -> Response: def post_view_two_factor_backup_codes(request: Request, code: str) -> Response:
"""Show the user their two-factor authentication backup codes.""" """Show the user their two-factor authentication backup codes."""
user = request.context user = request.context
@ -294,7 +294,7 @@ def patch_change_account_default_theme(request: Request) -> Response:
request_param="ic-trigger-name=user-bio", request_param="ic-trigger-name=user-bio",
permission="change_settings", permission="change_settings",
) )
@use_kwargs({"markdown": String()})
@use_kwargs({"markdown": String()}, location="form")
def patch_change_user_bio(request: Request, markdown: str) -> dict: def patch_change_user_bio(request: Request, markdown: str) -> dict:
"""Update a user's bio.""" """Update a user's bio."""
user = request.context user = request.context
@ -353,7 +353,7 @@ def get_invite_code(request: Request) -> dict:
"order": Enum(TopicSortOption), "order": Enum(TopicSortOption),
"period": ShortTimePeriod(allow_none=True, missing=None), "period": ShortTimePeriod(allow_none=True, missing=None),
}, },
locations=("form",), # will crash due to trying to find JSON data without this
location="form",
) )
def put_default_listing_options( def put_default_listing_options(
request: Request, order: TopicSortOption, period: Optional[SimpleHoursPeriod] request: Request, order: TopicSortOption, period: Optional[SimpleHoursPeriod]
@ -375,7 +375,7 @@ def put_default_listing_options(
request_method="PUT", request_method="PUT",
permission="change_settings", permission="change_settings",
) )
@use_kwargs({"tags": String()})
@use_kwargs({"tags": String()}, location="form")
def put_filtered_topic_tags(request: Request, tags: str) -> dict: def put_filtered_topic_tags(request: Request, tags: str) -> dict:
"""Update a user's filtered topic tags list.""" """Update a user's filtered topic tags list."""
if not tags or tags.isspace(): if not tags or tags.isspace():

4
tildes/tildes/views/bookmarks.py

@ -5,16 +5,16 @@ from typing import Optional, Type, Union
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.sql import desc from sqlalchemy.sql import desc
from webargs.pyramidparser import use_kwargs
from tildes.models.comment import Comment, CommentBookmark from tildes.models.comment import Comment, CommentBookmark
from tildes.models.topic import Topic, TopicBookmark from tildes.models.topic import Topic, TopicBookmark
from tildes.schemas.fields import PostType from tildes.schemas.fields import PostType
from tildes.schemas.listing import PaginatedListingSchema from tildes.schemas.listing import PaginatedListingSchema
from tildes.views.decorators import use_kwargs
@view_config(route_name="bookmarks", renderer="bookmarks.jinja2") @view_config(route_name="bookmarks", renderer="bookmarks.jinja2")
@use_kwargs(PaginatedListingSchema)
@use_kwargs(PaginatedListingSchema())
@use_kwargs({"post_type": PostType(data_key="type", missing="topic")}) @use_kwargs({"post_type": PostType(data_key="type", missing="topic")})
def get_bookmarks( def get_bookmarks(
request: Request, request: Request,

30
tildes/tildes/views/decorators.py

@ -3,11 +3,39 @@
"""Contains decorators for view functions.""" """Contains decorators for view functions."""
from typing import Any, Callable
from typing import Any, Callable, Dict, Union
from marshmallow import EXCLUDE
from marshmallow.fields import Field
from marshmallow.schema import Schema
from pyramid.httpexceptions import HTTPFound 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
from webargs import dict2schema, pyramidparser
def use_kwargs(
argmap: Union[Schema, Dict[str, Field]], location: str = "query", **kwargs: Any
) -> Callable:
"""Wrap the webargs @use_kwargs decorator with preferred default modifications.
Primarily, we want the location argument to default to "query" so that the data
comes from the query string. As of version 6.0, webargs defaults to "json", which is
almost never correct for Tildes.
We also need to set every schema's behavior for unknown fields to "exclude", so that
it just ignores them, instead of erroring when there's unexpected data (as there
almost always is, especially because of Intercooler).
"""
# convert a dict argmap to a Schema (the same way webargs would on its own)
if isinstance(argmap, dict):
argmap = dict2schema(argmap)()
assert isinstance(argmap, Schema) # tell mypy the type is more restricted now
argmap.unknown = EXCLUDE
return pyramidparser.use_kwargs(argmap, location=location, **kwargs)
def ic_view_config(**kwargs: Any) -> Callable: def ic_view_config(**kwargs: Any) -> Callable:

6
tildes/tildes/views/donate.py

@ -10,10 +10,9 @@ from pyramid.httpexceptions import HTTPInternalServerError
from pyramid.request import Request from pyramid.request import Request
from pyramid.security import NO_PERMISSION_REQUIRED from pyramid.security import NO_PERMISSION_REQUIRED
from pyramid.view import view_config from pyramid.view import view_config
from webargs.pyramidparser import use_kwargs
from tildes.metrics import incr_counter from tildes.metrics import incr_counter
from tildes.views.decorators import rate_limit_view
from tildes.views.decorators import rate_limit_view, use_kwargs
@view_config( @view_config(
@ -39,7 +38,8 @@ def get_donate_stripe(request: Request) -> dict:
"amount": Float(required=True, validate=Range(min=1.0)), "amount": Float(required=True, validate=Range(min=1.0)),
"currency": String(required=True, validate=OneOf(("CAD", "USD"))), "currency": String(required=True, validate=OneOf(("CAD", "USD"))),
"interval": String(required=True, validate=OneOf(("onetime", "month", "year"))), "interval": String(required=True, validate=OneOf(("onetime", "month", "year"))),
}
},
location="form",
) )
@rate_limit_view("donate_stripe") @rate_limit_view("donate_stripe")
def post_donate_stripe( def post_donate_stripe(

6
tildes/tildes/views/exceptions.py

@ -28,7 +28,11 @@ from tildes.models.group import Group
def errors_from_validationerror(validation_error: ValidationError) -> Sequence[str]: def errors_from_validationerror(validation_error: ValidationError) -> Sequence[str]:
"""Extract errors from a marshmallow ValidationError into a displayable format.""" """Extract errors from a marshmallow ValidationError into a displayable format."""
errors_by_field = validation_error.normalized_messages()
# as of webargs 6.0, errors are inside a nested dict, where the first level should
# always be a single-item dict with the key representing the "location" of the data
# (e.g. query, form, etc.) - we don't care about that, so just skip that level
errors_by_location = validation_error.normalized_messages()
errors_by_field = list(errors_by_location.values())[0]
error_strings = [] error_strings = []
for field, errors in errors_by_field.items(): for field, errors in errors_by_field.items():

8
tildes/tildes/views/group_wiki_page.py

@ -6,11 +6,11 @@
from pyramid.httpexceptions import HTTPFound 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
from webargs.pyramidparser import use_kwargs
from tildes.models.group import GroupWikiPage from tildes.models.group import GroupWikiPage
from tildes.schemas.fields import SimpleString from tildes.schemas.fields import SimpleString
from tildes.schemas.group_wiki_page import GroupWikiPageSchema from tildes.schemas.group_wiki_page import GroupWikiPageSchema
from tildes.views.decorators import use_kwargs
@view_config(route_name="group_wiki", renderer="group_wiki.jinja2") @view_config(route_name="group_wiki", renderer="group_wiki.jinja2")
@ -65,7 +65,7 @@ def get_wiki_new_page_form(request: Request) -> dict:
@view_config( @view_config(
route_name="group_wiki", request_method="POST", permission="wiki_page_create" route_name="group_wiki", request_method="POST", permission="wiki_page_create"
) )
@use_kwargs(GroupWikiPageSchema())
@use_kwargs(GroupWikiPageSchema(), location="form")
def post_group_wiki(request: Request, page_name: str, markdown: str) -> HTTPFound: def post_group_wiki(request: Request, page_name: str, markdown: str) -> HTTPFound:
"""Create a new wiki page in a group.""" """Create a new wiki page in a group."""
group = request.context group = request.context
@ -94,8 +94,8 @@ def get_wiki_edit_page_form(request: Request) -> dict:
@view_config(route_name="group_wiki_page", request_method="POST", permission="edit") @view_config(route_name="group_wiki_page", request_method="POST", permission="edit")
@use_kwargs(GroupWikiPageSchema(only=("markdown",)))
@use_kwargs({"edit_message": SimpleString(max_length=80)})
@use_kwargs(GroupWikiPageSchema(only=("markdown",)), location="form")
@use_kwargs({"edit_message": SimpleString(max_length=80)}, location="form")
def post_group_wiki_page(request: Request, markdown: str, edit_message: str) -> dict: def post_group_wiki_page(request: Request, markdown: str, edit_message: str) -> dict:
"""Apply an edit to a single group wiki page.""" """Apply an edit to a single group wiki page."""
page = request.context page = request.context

4
tildes/tildes/views/ignored_topics.py

@ -5,14 +5,14 @@ from typing import Optional
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.sql import desc from sqlalchemy.sql import desc
from webargs.pyramidparser import use_kwargs
from tildes.models.topic import Topic, TopicIgnore from tildes.models.topic import Topic, TopicIgnore
from tildes.schemas.listing import PaginatedListingSchema from tildes.schemas.listing import PaginatedListingSchema
from tildes.views.decorators import use_kwargs
@view_config(route_name="ignored_topics", renderer="ignored_topics.jinja2") @view_config(route_name="ignored_topics", renderer="ignored_topics.jinja2")
@use_kwargs(PaginatedListingSchema)
@use_kwargs(PaginatedListingSchema())
def get_ignored_topics( def get_ignored_topics(
request: Request, after: Optional[str], before: Optional[str], per_page: int, request: Request, after: Optional[str], before: Optional[str], per_page: int,
) -> dict: ) -> dict:

12
tildes/tildes/views/login.py

@ -14,14 +14,13 @@ from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from pyramid.security import NO_PERMISSION_REQUIRED, remember from pyramid.security import NO_PERMISSION_REQUIRED, remember
from pyramid.view import view_config from pyramid.view import view_config
from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType from tildes.enums import LogEventType
from tildes.metrics import incr_counter from tildes.metrics import incr_counter
from tildes.models.log import Log from tildes.models.log import Log
from tildes.models.user import User from tildes.models.user import User
from tildes.schemas.user import UserSchema from tildes.schemas.user import UserSchema
from tildes.views.decorators import not_logged_in, rate_limit_view
from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs
@view_config( @view_config(
@ -63,9 +62,10 @@ def finish_login(request: Request, user: User, redirect_url: str) -> HTTPFound:
@use_kwargs( @use_kwargs(
UserSchema( UserSchema(
only=("username", "password"), context={"username_trim_whitespace": True} only=("username", "password"), context={"username_trim_whitespace": True}
)
),
location="form",
) )
@use_kwargs({"from_url": String(missing="")})
@use_kwargs({"from_url": String(missing="")}, location="form")
@not_logged_in @not_logged_in
@rate_limit_view("login") @rate_limit_view("login")
def post_login( def post_login(
@ -147,7 +147,9 @@ def post_login(
) )
@not_logged_in @not_logged_in
@rate_limit_view("login_two_factor") @rate_limit_view("login_two_factor")
@use_kwargs({"code": String(missing=""), "from_url": String(missing="")})
@use_kwargs(
{"code": String(missing=""), "from_url": String(missing="")}, location="form"
)
def post_login_two_factor(request: Request, code: str, from_url: str) -> NoReturn: def post_login_two_factor(request: Request, code: str, from_url: str) -> NoReturn:
"""Process a log in request with 2FA.""" """Process a log in request with 2FA."""
# Look up the user for the supplied username # Look up the user for the supplied username

6
tildes/tildes/views/message.py

@ -9,10 +9,10 @@ from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.dialects.postgresql import array from sqlalchemy.dialects.postgresql import array
from sqlalchemy.sql.expression import and_, or_ from sqlalchemy.sql.expression import and_, or_
from webargs.pyramidparser import use_kwargs
from tildes.models.message import MessageConversation, MessageReply from tildes.models.message import MessageConversation, MessageReply
from tildes.schemas.message import MessageConversationSchema, MessageReplySchema from tildes.schemas.message import MessageConversationSchema, MessageReplySchema
from tildes.views.decorators import use_kwargs
@view_config( @view_config(
@ -113,7 +113,7 @@ def get_message_conversation(request: Request) -> dict:
@view_config( @view_config(
route_name="message_conversation", request_method="POST", permission="reply" route_name="message_conversation", request_method="POST", permission="reply"
) )
@use_kwargs(MessageReplySchema(only=("markdown",)))
@use_kwargs(MessageReplySchema(only=("markdown",)), location="form")
def post_message_reply(request: Request, markdown: str) -> HTTPFound: def post_message_reply(request: Request, markdown: str) -> HTTPFound:
"""Post a reply to a message conversation.""" """Post a reply to a message conversation."""
conversation = request.context conversation = request.context
@ -129,7 +129,7 @@ def post_message_reply(request: Request, markdown: str) -> HTTPFound:
@view_config(route_name="user_messages", request_method="POST", permission="message") @view_config(route_name="user_messages", request_method="POST", permission="message")
@use_kwargs(MessageConversationSchema(only=("subject", "markdown")))
@use_kwargs(MessageConversationSchema(only=("subject", "markdown")), location="form")
def post_user_message(request: Request, subject: str, markdown: str) -> HTTPFound: def post_user_message(request: Request, subject: str, markdown: str) -> HTTPFound:
"""Start a new message conversation with a user.""" """Start a new message conversation with a user."""
new_conversation = MessageConversation( new_conversation = MessageConversation(

2
tildes/tildes/views/notifications.py

@ -8,11 +8,11 @@ from typing import Optional
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.sql.expression import desc from sqlalchemy.sql.expression import desc
from webargs.pyramidparser import use_kwargs
from tildes.enums import CommentLabelOption from tildes.enums import CommentLabelOption
from tildes.models.comment import CommentNotification from tildes.models.comment import CommentNotification
from tildes.schemas.listing import PaginatedListingSchema from tildes.schemas.listing import PaginatedListingSchema
from tildes.views.decorators import use_kwargs
@view_config(route_name="notifications_unread", renderer="notifications_unread.jinja2") @view_config(route_name="notifications_unread", renderer="notifications_unread.jinja2")

26
tildes/tildes/views/register.py

@ -9,7 +9,6 @@ from pyramid.request import Request
from pyramid.security import NO_PERMISSION_REQUIRED, remember from pyramid.security import NO_PERMISSION_REQUIRED, remember
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType from tildes.enums import LogEventType
from tildes.metrics import incr_counter from tildes.metrics import incr_counter
@ -17,7 +16,7 @@ from tildes.models.group import Group, GroupSubscription
from tildes.models.log import Log from tildes.models.log import Log
from tildes.models.user import User, UserInviteCode from tildes.models.user import User, UserInviteCode
from tildes.schemas.user import UserSchema from tildes.schemas.user import UserSchema
from tildes.views.decorators import not_logged_in, rate_limit_view
from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs
@view_config( @view_config(
@ -31,25 +30,18 @@ def get_register(request: Request, code: str) -> dict:
return {"code": code} return {"code": code}
def user_schema_check_breaches(request: Request) -> UserSchema:
"""Return a UserSchema that will check the password against breaches.
It would probably be good to generalize this function at some point, probably
similar to:
http://webargs.readthedocs.io/en/latest/advanced.html#reducing-boilerplate
"""
# pylint: disable=unused-argument
return UserSchema(
only=("username", "password"), context={"check_breached_passwords": True}
)
@view_config( @view_config(
route_name="register", request_method="POST", permission=NO_PERMISSION_REQUIRED route_name="register", request_method="POST", permission=NO_PERMISSION_REQUIRED
) )
@use_kwargs(user_schema_check_breaches)
@use_kwargs( @use_kwargs(
{"invite_code": String(required=True), "password_confirm": String(required=True)}
UserSchema(
only=("username", "password"), context={"check_breached_passwords": True}
),
location="form",
)
@use_kwargs(
{"invite_code": String(required=True), "password_confirm": String(required=True)},
location="form",
) )
@not_logged_in @not_logged_in
@rate_limit_view("register") @rate_limit_view("register")

5
tildes/tildes/views/settings.py

@ -14,7 +14,6 @@ from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy import func from sqlalchemy import func
from webargs.pyramidparser import use_kwargs
from tildes.enums import CommentLabelOption, CommentTreeSortOption from tildes.enums import CommentLabelOption, CommentTreeSortOption
from tildes.lib.datetime import utc_now from tildes.lib.datetime import utc_now
@ -28,6 +27,7 @@ from tildes.schemas.user import (
EMAIL_ADDRESS_NOTE_MAX_LENGTH, EMAIL_ADDRESS_NOTE_MAX_LENGTH,
UserSchema, UserSchema,
) )
from tildes.views.decorators import use_kwargs
PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"] PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"]
@ -139,7 +139,8 @@ def get_settings_bio(request: Request) -> dict:
"old_password": PASSWORD_FIELD, "old_password": PASSWORD_FIELD,
"new_password": PASSWORD_FIELD, "new_password": PASSWORD_FIELD,
"new_password_confirm": PASSWORD_FIELD, "new_password_confirm": PASSWORD_FIELD,
}
},
location="form",
) )
def post_settings_password_change( def post_settings_password_change(
request: Request, old_password: str, new_password: str, new_password_confirm: str request: Request, old_password: str, new_password: str, new_password_confirm: str

9
tildes/tildes/views/topic.py

@ -19,7 +19,6 @@ from sqlalchemy import cast
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import any_, desc from sqlalchemy.sql.expression import any_, desc
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
from webargs.pyramidparser import use_kwargs
from tildes.enums import ( from tildes.enums import (
CommentLabelOption, CommentLabelOption,
@ -39,7 +38,7 @@ from tildes.schemas.comment import CommentSchema
from tildes.schemas.fields import Enum, ShortTimePeriod from tildes.schemas.fields import Enum, ShortTimePeriod
from tildes.schemas.listing import TopicListingSchema from tildes.schemas.listing import TopicListingSchema
from tildes.schemas.topic import TopicSchema from tildes.schemas.topic import TopicSchema
from tildes.views.decorators import rate_limit_view
from tildes.views.decorators import rate_limit_view, use_kwargs
from tildes.views.financials import get_financial_data from tildes.views.financials import get_financial_data
@ -47,10 +46,10 @@ DefaultSettings = namedtuple("DefaultSettings", ["order", "period"])
@view_config(route_name="group_topics", request_method="POST", permission="post_topic") @view_config(route_name="group_topics", request_method="POST", permission="post_topic")
@use_kwargs(TopicSchema(only=("title", "markdown", "link")))
@use_kwargs(TopicSchema(only=("title", "markdown", "link")), location="form")
@use_kwargs( @use_kwargs(
{"tags": String(missing=""), "confirm_repost": Boolean(missing=False)}, {"tags": String(missing=""), "confirm_repost": Boolean(missing=False)},
locations=("form",), # will crash due to trying to find JSON data without this
location="form",
) )
def post_group_topics( def post_group_topics(
request: Request, request: Request,
@ -470,7 +469,7 @@ def get_topic(request: Request, comment_order: CommentTreeSortOption) -> 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",)), location="form")
@rate_limit_view("comment_post") @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."""

2
tildes/tildes/views/user.py

@ -10,7 +10,6 @@ from marshmallow.fields import String
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.sql.expression import desc from sqlalchemy.sql.expression import desc
from webargs.pyramidparser import use_kwargs
from tildes.enums import CommentLabelOption, CommentSortOption, TopicSortOption from tildes.enums import CommentLabelOption, CommentSortOption, TopicSortOption
from tildes.models.comment import Comment from tildes.models.comment import Comment
@ -19,6 +18,7 @@ from tildes.models.topic import Topic
from tildes.models.user import User, UserInviteCode from tildes.models.user import User, UserInviteCode
from tildes.schemas.fields import PostType from tildes.schemas.fields import PostType
from tildes.schemas.listing import MixedListingSchema from tildes.schemas.listing import MixedListingSchema
from tildes.views.decorators import use_kwargs
@view_config(route_name="user", renderer="user.jinja2") @view_config(route_name="user", renderer="user.jinja2")

4
tildes/tildes/views/votes.py

@ -5,16 +5,16 @@ from typing import Optional, Type, Union
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy.sql import desc from sqlalchemy.sql import desc
from webargs.pyramidparser import use_kwargs
from tildes.models.comment import Comment, CommentVote from tildes.models.comment import Comment, CommentVote
from tildes.models.topic import Topic, TopicVote from tildes.models.topic import Topic, TopicVote
from tildes.schemas.fields import PostType from tildes.schemas.fields import PostType
from tildes.schemas.listing import PaginatedListingSchema from tildes.schemas.listing import PaginatedListingSchema
from tildes.views.decorators import use_kwargs
@view_config(route_name="votes", renderer="votes.jinja2") @view_config(route_name="votes", renderer="votes.jinja2")
@use_kwargs(PaginatedListingSchema)
@use_kwargs(PaginatedListingSchema())
@use_kwargs({"post_type": PostType(data_key="type", missing="topic")}) @use_kwargs({"post_type": PostType(data_key="type", missing="topic")})
def get_voted_posts( def get_voted_posts(
request: Request, request: Request,

Loading…
Cancel
Save