mirror of
https://gitlab.com/tildes/tildes.git
synced 2026-04-17 06:48:36 +02:00
@@ -37,7 +37,7 @@ jinja2==3.1.6
|
||||
lupa==2.5
|
||||
mako==1.3.10
|
||||
markupsafe==3.0.2
|
||||
marshmallow==3.25.1
|
||||
marshmallow==4.0.1
|
||||
matplotlib-inline==0.1.7
|
||||
mccabe==0.7.0
|
||||
mypy==1.17.1
|
||||
@@ -119,7 +119,7 @@ urllib3==2.5.0
|
||||
venusian==3.1.1
|
||||
waitress==3.0.2
|
||||
wcwidth==0.2.13
|
||||
webargs==8.0.0
|
||||
webargs==8.7.0
|
||||
webassets==2.0
|
||||
webencodings==0.5.1
|
||||
webob==1.8.9
|
||||
|
||||
@@ -10,7 +10,7 @@ html5lib
|
||||
invoke
|
||||
ipython
|
||||
lupa
|
||||
marshmallow==3.25.1 # TODO: Upgrade Marshmallow https://marshmallow.readthedocs.io/en/latest/upgrading.html
|
||||
marshmallow
|
||||
Pillow
|
||||
pip-tools
|
||||
prometheus-client
|
||||
@@ -36,6 +36,6 @@ SQLAlchemy-Utils
|
||||
stripe==2.6.0 # TODO: Figure out if we can update this
|
||||
titlecase
|
||||
unicodedata2
|
||||
webargs==8.0.0 # TODO: Updating webargs causes an issue with parsing URLs, figure out if we can fix this
|
||||
webargs
|
||||
wrapt
|
||||
zope.sqlalchemy==1.5 # TODO: Figure out if we can update this
|
||||
|
||||
@@ -25,7 +25,7 @@ jinja2==3.1.6
|
||||
lupa==2.5
|
||||
mako==1.3.10
|
||||
markupsafe==3.0.2
|
||||
marshmallow==3.25.1
|
||||
marshmallow==4.0.1
|
||||
matplotlib-inline==0.1.7
|
||||
packaging==25.0
|
||||
parso==0.8.4
|
||||
@@ -73,7 +73,7 @@ unicodedata2==16.0.0
|
||||
urllib3==2.5.0
|
||||
venusian==3.1.1
|
||||
wcwidth==0.2.13
|
||||
webargs==8.0.0
|
||||
webargs==8.7.0
|
||||
webassets==2.0
|
||||
webencodings==0.5.1
|
||||
webob==1.8.9
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from marshmallow import Schema, ValidationError
|
||||
from marshmallow import ValidationError
|
||||
from pytest import raises
|
||||
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Markdown
|
||||
|
||||
|
||||
class MarkdownFieldTestSchema(Schema):
|
||||
class MarkdownFieldTestSchema(BaseTildesSchema):
|
||||
"""Simple schema class with a standard Markdown field."""
|
||||
|
||||
markdown = Markdown()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from marshmallow import Schema, ValidationError
|
||||
from marshmallow import ValidationError
|
||||
from pytest import raises
|
||||
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import SimpleString
|
||||
|
||||
|
||||
class SimpleStringTestSchema(Schema):
|
||||
class SimpleStringTestSchema(BaseTildesSchema):
|
||||
"""Simple schema class with a standard SimpleString field."""
|
||||
|
||||
subject = SimpleString()
|
||||
|
||||
@@ -11,6 +11,7 @@ from tildes.models import DatabaseModel
|
||||
from tildes.models.group import Group
|
||||
from tildes.models.topic import Topic
|
||||
from tildes.models.user import User
|
||||
from tildes.schemas.context import TildesSchemaContext, TildesSchemaContextDict
|
||||
|
||||
|
||||
def serialize_model(model_item: DatabaseModel, request: Request) -> dict:
|
||||
@@ -25,11 +26,12 @@ def serialize_model(model_item: DatabaseModel, request: Request) -> dict:
|
||||
|
||||
def serialize_topic(topic: Topic, request: Request) -> dict:
|
||||
"""Return serializable data for a Topic."""
|
||||
context = {}
|
||||
context: TildesSchemaContextDict = {}
|
||||
if not request.has_permission("view_author", topic):
|
||||
context["hide_username"] = True
|
||||
|
||||
return topic.schema_class(context=context).dump(topic)
|
||||
with TildesSchemaContext(context):
|
||||
return topic.schema_class().dump(topic)
|
||||
|
||||
|
||||
def includeme(config: Configurator) -> None:
|
||||
|
||||
37
tildes/tildes/schemas/base.py
Normal file
37
tildes/tildes/schemas/base.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Base Marshmallow schema."""
|
||||
|
||||
|
||||
from typing import Any, Optional
|
||||
from marshmallow import Schema
|
||||
from tildes.schemas.context import TildesSchemaContext, TildesSchemaContextDict
|
||||
|
||||
|
||||
class BaseTildesSchema(Schema):
|
||||
"""Base Marshmallow schema for Tildes schemas.
|
||||
|
||||
Adds common code like the context dict.
|
||||
"""
|
||||
|
||||
context: TildesSchemaContextDict
|
||||
|
||||
def __init__(
|
||||
self, context: Optional[TildesSchemaContextDict] = None, **kwargs: Any
|
||||
):
|
||||
"""Pass an optional context, and forward Schema arguments to superclass."""
|
||||
super().__init__(**kwargs)
|
||||
self.context = context if context else {}
|
||||
|
||||
def get_context_value(self, key: str) -> Any:
|
||||
"""Get a value from the context dict.
|
||||
|
||||
Any active TildesSchemaContext, e.g. set using a "with" statement,
|
||||
takes precedence. If there is no active TildesSchemaContext, then
|
||||
it takes the value from the dict passed in __init__ instead.
|
||||
"""
|
||||
result = TildesSchemaContext.get(default=self.context).get(key)
|
||||
if result:
|
||||
return result
|
||||
return self.context.get(key)
|
||||
@@ -3,13 +3,12 @@
|
||||
|
||||
"""Validation/dumping schema for comments."""
|
||||
|
||||
from marshmallow import Schema
|
||||
|
||||
from tildes.enums import CommentLabelOption
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Enum, ID36, Markdown, SimpleString
|
||||
|
||||
|
||||
class CommentSchema(Schema):
|
||||
class CommentSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for comments."""
|
||||
|
||||
comment_id36 = ID36()
|
||||
@@ -17,8 +16,8 @@ class CommentSchema(Schema):
|
||||
parent_comment_id36 = ID36()
|
||||
|
||||
|
||||
class CommentLabelSchema(Schema):
|
||||
class CommentLabelSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for comment labels."""
|
||||
|
||||
name = Enum(CommentLabelOption)
|
||||
reason = SimpleString(max_length=1000, missing=None)
|
||||
reason = SimpleString(max_length=1000, load_default=None)
|
||||
|
||||
30
tildes/tildes/schemas/context.py
Normal file
30
tildes/tildes/schemas/context.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Context variables that can be used with Marshmallow schemas."""
|
||||
import typing
|
||||
|
||||
from marshmallow.experimental.context import Context
|
||||
|
||||
|
||||
class TildesSchemaContextDict(typing.TypedDict, total=False):
|
||||
"""Context for Tildes Marshmallow schemas.
|
||||
|
||||
For convenience, we use one unified class instead of one per schema,
|
||||
so it can be passed down through different schemas in a subgraph.
|
||||
For example, if a Topic contains a reference to a User,
|
||||
one instance of TildesContext can configure both the Topic and User.
|
||||
"""
|
||||
|
||||
# Applies to UserSchema
|
||||
hide_username: bool
|
||||
# Applies to UserSchema
|
||||
check_breached_passwords: bool
|
||||
# Applies to UserSchema
|
||||
username_trim_whitespace: bool
|
||||
|
||||
# Applies to GroupSchema
|
||||
fix_path_capitalization: bool
|
||||
|
||||
|
||||
TildesSchemaContext = Context[TildesSchemaContextDict]
|
||||
@@ -22,7 +22,7 @@ from tildes.lib.string import simplify_string
|
||||
DataType = Optional[Mapping[str, Any]]
|
||||
|
||||
|
||||
class Enum(Field):
|
||||
class Enum(Field[enum.Enum]):
|
||||
"""Field for a native Python Enum (or subclasses)."""
|
||||
|
||||
def __init__(
|
||||
@@ -34,9 +34,12 @@ class Enum(Field):
|
||||
self._enum_class = enum_class
|
||||
|
||||
def _serialize(
|
||||
self, value: enum.Enum, attr: str | None, obj: object, **kwargs: Any
|
||||
) -> str:
|
||||
self, value: enum.Enum | None, attr: str | None, obj: object, **kwargs: Any
|
||||
) -> str | None:
|
||||
"""Serialize the enum value - lowercase version of its name."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return value.name.lower()
|
||||
|
||||
def _deserialize(
|
||||
@@ -64,7 +67,7 @@ class ID36(String):
|
||||
super().__init__(validate=Regexp(ID36_REGEX), **kwargs)
|
||||
|
||||
|
||||
class ShortTimePeriod(Field):
|
||||
class ShortTimePeriod(Field[Optional[SimpleHoursPeriod]]):
|
||||
"""Field for short time period strings like "4h" and "2d".
|
||||
|
||||
Also supports the string "all" which will be converted to None.
|
||||
@@ -100,7 +103,7 @@ class ShortTimePeriod(Field):
|
||||
return value.as_short_form()
|
||||
|
||||
|
||||
class Markdown(Field):
|
||||
class Markdown(Field[str]):
|
||||
"""Field for markdown strings (comments, text topic, messages, etc.)."""
|
||||
|
||||
DEFAULT_MAX_LENGTH = 50000
|
||||
@@ -132,13 +135,13 @@ class Markdown(Field):
|
||||
return value
|
||||
|
||||
def _serialize(
|
||||
self, value: str, attr: str | None, obj: object, **kwargs: Any
|
||||
) -> str:
|
||||
self, value: str | None, attr: str | None, obj: object, **kwargs: Any
|
||||
) -> str | None:
|
||||
"""Serialize the value (no-op in this case)."""
|
||||
return value
|
||||
|
||||
|
||||
class SimpleString(Field):
|
||||
class SimpleString(Field[str]):
|
||||
"""Field for "simple" strings, suitable for uses like subject, title, etc.
|
||||
|
||||
These strings should generally not contain any special formatting (such as
|
||||
@@ -169,13 +172,13 @@ class SimpleString(Field):
|
||||
return simplify_string(value)
|
||||
|
||||
def _serialize(
|
||||
self, value: str, attr: str | None, obj: object, **kwargs: Any
|
||||
) -> str:
|
||||
self, value: str | None, attr: str | None, obj: object, **kwargs: Any
|
||||
) -> str | None:
|
||||
"""Serialize the value (no-op in this case)."""
|
||||
return value
|
||||
|
||||
|
||||
class Ltree(Field):
|
||||
class Ltree(Field[sqlalchemy_utils.Ltree]):
|
||||
"""Field for postgresql ltree type."""
|
||||
|
||||
# note that this regex only checks whether all of the chars are individually valid,
|
||||
|
||||
@@ -7,10 +7,12 @@ import re
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy_utils
|
||||
from marshmallow import pre_load, Schema, validates
|
||||
from marshmallow import pre_load, validates
|
||||
from marshmallow.exceptions import ValidationError
|
||||
from marshmallow.fields import DateTime
|
||||
from marshmallow.types import UnknownOption
|
||||
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Ltree, Markdown, SimpleString
|
||||
|
||||
|
||||
@@ -30,7 +32,7 @@ GROUP_PATH_ELEMENT_VALID_REGEX = re.compile(
|
||||
SHORT_DESCRIPTION_MAX_LENGTH = 200
|
||||
|
||||
|
||||
class GroupSchema(Schema):
|
||||
class GroupSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for groups."""
|
||||
|
||||
path = Ltree(required=True)
|
||||
@@ -41,10 +43,12 @@ class GroupSchema(Schema):
|
||||
sidebar_markdown = Markdown(allow_none=True)
|
||||
|
||||
@pre_load
|
||||
def prepare_path(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def prepare_path(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the path value before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if not self.context.get("fix_path_capitalization"):
|
||||
if not self.get_context_value("fix_path_capitalization"):
|
||||
return data
|
||||
|
||||
if "path" not in data or not isinstance(data["path"], str):
|
||||
@@ -57,8 +61,9 @@ class GroupSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@validates("path")
|
||||
def validate_path(self, value: sqlalchemy_utils.Ltree) -> None:
|
||||
def validate_path(self, value: sqlalchemy_utils.Ltree, data_key: str) -> None:
|
||||
"""Validate the path field, raising an error if an issue exists."""
|
||||
# pylint: disable=unused-argument
|
||||
# check each element for length and against validity regex
|
||||
path_elements = value.path.split(".")
|
||||
for element in path_elements:
|
||||
@@ -69,7 +74,9 @@ class GroupSchema(Schema):
|
||||
raise ValidationError("Path element %s is invalid" % element)
|
||||
|
||||
@pre_load
|
||||
def prepare_sidebar_markdown(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def prepare_sidebar_markdown(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the sidebar_markdown value before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "sidebar_markdown" not in data:
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
|
||||
"""Validation/dumping schema for group wiki pages."""
|
||||
|
||||
from marshmallow import Schema
|
||||
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Markdown, SimpleString
|
||||
|
||||
|
||||
PAGE_NAME_MAX_LENGTH = 40
|
||||
|
||||
|
||||
class GroupWikiPageSchema(Schema):
|
||||
class GroupWikiPageSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for group wiki pages."""
|
||||
|
||||
page_name = SimpleString(max_length=PAGE_NAME_MAX_LENGTH)
|
||||
|
||||
@@ -5,23 +5,27 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import pre_load, Schema, validates_schema, ValidationError
|
||||
from marshmallow import pre_load, validates_schema, ValidationError
|
||||
from marshmallow.fields import Boolean, Integer
|
||||
from marshmallow.types import UnknownOption
|
||||
from marshmallow.validate import Range
|
||||
|
||||
from tildes.enums import TopicSortOption
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Enum, ID36, Ltree, PostType, ShortTimePeriod
|
||||
|
||||
|
||||
class PaginatedListingSchema(Schema):
|
||||
class PaginatedListingSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema to validate arguments for a paginated listing page."""
|
||||
|
||||
after = ID36(missing=None)
|
||||
before = ID36(missing=None)
|
||||
per_page = Integer(validate=Range(min=1, max=100), missing=50)
|
||||
after = ID36(load_default=None)
|
||||
before = ID36(load_default=None)
|
||||
per_page = Integer(validate=Range(min=1, max=100), load_default=50)
|
||||
|
||||
@validates_schema
|
||||
def either_after_or_before(self, data: dict, many: bool, partial: Any) -> None:
|
||||
def either_after_or_before(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> None:
|
||||
"""Fail validation if both after and before were specified."""
|
||||
# pylint: disable=unused-argument
|
||||
if data.get("after") and data.get("before"):
|
||||
@@ -32,15 +36,15 @@ class TopicListingSchema(PaginatedListingSchema):
|
||||
"""Marshmallow schema to validate arguments for a topic listing page."""
|
||||
|
||||
period = ShortTimePeriod(allow_none=True)
|
||||
order = Enum(TopicSortOption, missing=None)
|
||||
tag = Ltree(missing=None)
|
||||
unfiltered = Boolean(missing=False)
|
||||
all_subgroups = Boolean(missing=False)
|
||||
rank_start = Integer(data_key="n", validate=Range(min=1), missing=None)
|
||||
order = Enum(TopicSortOption, load_default=None)
|
||||
tag = Ltree(load_default=None)
|
||||
unfiltered = Boolean(load_default=False)
|
||||
all_subgroups = Boolean(load_default=False)
|
||||
rank_start = Integer(data_key="n", validate=Range(min=1), load_default=None)
|
||||
|
||||
@pre_load
|
||||
def reset_rank_start_on_first_page(
|
||||
self, data: dict, many: bool, partial: Any
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Reset rank_start to 1 if this is a first page (no before/after)."""
|
||||
# pylint: disable=unused-argument
|
||||
@@ -62,11 +66,11 @@ class MixedListingSchema(PaginatedListingSchema):
|
||||
of just one or the other.
|
||||
"""
|
||||
|
||||
anchor_type = PostType(missing=None)
|
||||
anchor_type = PostType(load_default=None)
|
||||
|
||||
@pre_load
|
||||
def set_anchor_type_from_before_or_after(
|
||||
self, data: dict, many: bool, partial: Any
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Set the anchor_type if before or after has a special value indicating type.
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
|
||||
"""Validation/dumping schemas for messages."""
|
||||
|
||||
from marshmallow import Schema
|
||||
from marshmallow.fields import DateTime, String
|
||||
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import ID36, Markdown, SimpleString
|
||||
|
||||
|
||||
SUBJECT_MAX_LENGTH = 200
|
||||
|
||||
|
||||
class MessageConversationSchema(Schema):
|
||||
class MessageConversationSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for message conversations."""
|
||||
|
||||
conversation_id36 = ID36()
|
||||
@@ -22,7 +22,7 @@ class MessageConversationSchema(Schema):
|
||||
created_time = DateTime(dump_only=True)
|
||||
|
||||
|
||||
class MessageReplySchema(Schema):
|
||||
class MessageReplySchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for message replies."""
|
||||
|
||||
reply_id36 = ID36()
|
||||
|
||||
@@ -7,10 +7,12 @@ import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from marshmallow import pre_load, Schema, validates, validates_schema, ValidationError
|
||||
from marshmallow import pre_load, validates, validates_schema, ValidationError
|
||||
from marshmallow.fields import DateTime, List, Nested, String, URL
|
||||
from marshmallow.types import UnknownOption
|
||||
|
||||
from tildes.lib.url_transform import apply_url_transformations
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Enum, ID36, Markdown, SimpleString
|
||||
from tildes.schemas.group import GroupSchema
|
||||
from tildes.schemas.user import UserSchema
|
||||
@@ -20,7 +22,7 @@ TITLE_MAX_LENGTH = 200
|
||||
TAG_SYNONYMS = {"spoiler": ["spoilers"]}
|
||||
|
||||
|
||||
class TopicSchema(Schema):
|
||||
class TopicSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for topics."""
|
||||
|
||||
topic_id36 = ID36()
|
||||
@@ -36,7 +38,9 @@ class TopicSchema(Schema):
|
||||
group = Nested(GroupSchema, dump_only=True)
|
||||
|
||||
@pre_load
|
||||
def prepare_title(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def prepare_title(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the title before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "title" not in data:
|
||||
@@ -56,7 +60,9 @@ class TopicSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@pre_load
|
||||
def prepare_tags(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def prepare_tags(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the tags before they're validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "tags" not in data:
|
||||
@@ -98,7 +104,7 @@ class TopicSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@validates("tags")
|
||||
def validate_tags(self, value: list[str]) -> None:
|
||||
def validate_tags(self, value: list[str], data_key: str) -> None:
|
||||
"""Validate the tags field, raising an error if an issue exists.
|
||||
|
||||
Note that tags are validated by ensuring that each tag would be a valid group
|
||||
@@ -107,6 +113,7 @@ class TopicSchema(Schema):
|
||||
between groups and tags. For example, a popular tag in a group could be
|
||||
converted into a sub-group easily.
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
group_schema = GroupSchema(partial=True)
|
||||
for tag in value:
|
||||
try:
|
||||
@@ -115,7 +122,9 @@ class TopicSchema(Schema):
|
||||
raise ValidationError("Tag %s is invalid" % tag) from exc
|
||||
|
||||
@pre_load
|
||||
def prepare_markdown(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def prepare_markdown(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the markdown value before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "markdown" not in data:
|
||||
@@ -130,7 +139,9 @@ class TopicSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@pre_load
|
||||
def prepare_link(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def prepare_link(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the link value before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "link" not in data:
|
||||
@@ -157,7 +168,9 @@ class TopicSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@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, unknown: UnknownOption
|
||||
) -> None:
|
||||
"""Fail validation unless at least one of link or markdown were set."""
|
||||
# pylint: disable=unused-argument
|
||||
if "link" not in data and "markdown" not in data:
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import post_dump, pre_load, Schema, validates, validates_schema
|
||||
from marshmallow import post_dump, pre_load, validates, validates_schema
|
||||
from marshmallow.exceptions import ValidationError
|
||||
from marshmallow.fields import DateTime, Email, String
|
||||
from marshmallow.types import UnknownOption
|
||||
from marshmallow.validate import Length, Regexp
|
||||
|
||||
from tildes.lib.password import is_breached_password
|
||||
from tildes.schemas.base import BaseTildesSchema
|
||||
from tildes.schemas.fields import Markdown
|
||||
|
||||
|
||||
@@ -41,7 +43,7 @@ EMAIL_ADDRESS_NOTE_MAX_LENGTH = 100
|
||||
BIO_MAX_LENGTH = 2000
|
||||
|
||||
|
||||
class UserSchema(Schema):
|
||||
class UserSchema(BaseTildesSchema):
|
||||
"""Marshmallow schema for users."""
|
||||
|
||||
username = String(
|
||||
@@ -63,7 +65,7 @@ class UserSchema(Schema):
|
||||
def anonymize_username(self, data: dict, many: bool) -> dict:
|
||||
"""Hide the username if the dumping context specifies to do so."""
|
||||
# pylint: disable=unused-argument
|
||||
if not self.context.get("hide_username"):
|
||||
if not self.get_context_value("hide_username"):
|
||||
return data
|
||||
|
||||
if "username" not in data:
|
||||
@@ -77,7 +79,7 @@ class UserSchema(Schema):
|
||||
|
||||
@validates_schema
|
||||
def username_pass_not_substrings(
|
||||
self, data: dict, many: bool, partial: Any
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> None:
|
||||
"""Ensure the username isn't in the password and vice versa."""
|
||||
# pylint: disable=unused-argument
|
||||
@@ -96,12 +98,13 @@ class UserSchema(Schema):
|
||||
raise ValidationError("Username cannot contain password")
|
||||
|
||||
@validates("password")
|
||||
def password_not_breached(self, value: str) -> None:
|
||||
def password_not_breached(self, value: str, data_key: str) -> None:
|
||||
"""Validate that the password is not in the breached-passwords list.
|
||||
|
||||
Requires check_breached_passwords be True in the schema's context.
|
||||
"""
|
||||
if not self.context.get("check_breached_passwords"):
|
||||
# pylint: disable=unused-argument
|
||||
if not self.get_context_value("check_breached_passwords"):
|
||||
return
|
||||
|
||||
if is_breached_password(value):
|
||||
@@ -111,13 +114,15 @@ class UserSchema(Schema):
|
||||
)
|
||||
|
||||
@pre_load
|
||||
def username_trim_whitespace(self, data: dict, many: bool, partial: Any) -> dict:
|
||||
def username_trim_whitespace(
|
||||
self, data: dict, many: bool, partial: Any, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Trim leading/trailing whitespace around the username.
|
||||
|
||||
Requires username_trim_whitespace be True in the schema's context.
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
if not self.context.get("username_trim_whitespace"):
|
||||
if not self.get_context_value("username_trim_whitespace"):
|
||||
return data
|
||||
|
||||
if "username" not in data:
|
||||
@@ -130,7 +135,9 @@ class UserSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@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, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the email address value before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "email_address" not in data:
|
||||
@@ -148,7 +155,9 @@ class UserSchema(Schema):
|
||||
return new_data
|
||||
|
||||
@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, unknown: UnknownOption
|
||||
) -> dict:
|
||||
"""Prepare the bio_markdown value before it's validated."""
|
||||
# pylint: disable=unused-argument
|
||||
if "bio_markdown" not in data:
|
||||
|
||||
@@ -335,7 +335,7 @@ def delete_label_comment(request: Request, name: CommentLabelOption) -> Response
|
||||
@ic_view_config(
|
||||
route_name="comment_mark_read", request_method="PUT", permission="mark_read"
|
||||
)
|
||||
@use_kwargs({"mark_all_previous": Boolean(missing=False)}, location="query")
|
||||
@use_kwargs({"mark_all_previous": Boolean(load_default=False)}, location="query")
|
||||
def put_mark_comments_read(request: Request, mark_all_previous: bool) -> Response:
|
||||
"""Mark comment(s) read, clearing notifications.
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def delete_subscribe_group(request: Request) -> dict:
|
||||
@use_kwargs(
|
||||
{
|
||||
"order": Enum(TopicSortOption),
|
||||
"period": ShortTimePeriod(allow_none=True, missing=None),
|
||||
"period": ShortTimePeriod(allow_none=True, load_default=None),
|
||||
},
|
||||
location="form",
|
||||
)
|
||||
|
||||
@@ -158,7 +158,8 @@ def get_topic_tags(request: Request) -> dict:
|
||||
permission="tag",
|
||||
)
|
||||
@use_kwargs(
|
||||
{"tags": String(missing=""), "conflict_check": String(missing="")}, location="form"
|
||||
{"tags": String(load_default=""), "conflict_check": String(load_default="")},
|
||||
location="form",
|
||||
)
|
||||
def put_tag_topic(request: Request, tags: str, conflict_check: str) -> dict:
|
||||
"""Apply tags to a topic with Intercooler."""
|
||||
|
||||
@@ -23,6 +23,7 @@ from tildes.lib.datetime import SimpleHoursPeriod
|
||||
from tildes.lib.string import separate_string
|
||||
from tildes.models.log import Log
|
||||
from tildes.models.user import User, UserInviteCode
|
||||
from tildes.schemas.context import TildesSchemaContext, TildesSchemaContextDict
|
||||
from tildes.schemas.fields import Enum, ShortTimePeriod
|
||||
from tildes.schemas.topic import TopicSchema
|
||||
from tildes.schemas.user import UserSchema
|
||||
@@ -54,12 +55,13 @@ def patch_change_password(
|
||||
user = request.context
|
||||
|
||||
# enable checking the new password against the breached-passwords list
|
||||
user.schema.context["check_breached_passwords"] = True
|
||||
context: TildesSchemaContextDict = {"check_breached_passwords": True}
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise HTTPUnprocessableEntity("New password and confirmation do not match.")
|
||||
|
||||
user.change_password(old_password, new_password)
|
||||
with TildesSchemaContext(context):
|
||||
user.change_password(old_password, new_password)
|
||||
|
||||
return Response("Your password has been updated")
|
||||
|
||||
@@ -357,7 +359,7 @@ def get_invite_code(request: Request) -> dict:
|
||||
@use_kwargs(
|
||||
{
|
||||
"order": Enum(TopicSortOption),
|
||||
"period": ShortTimePeriod(allow_none=True, missing=None),
|
||||
"period": ShortTimePeriod(allow_none=True, load_default=None),
|
||||
},
|
||||
location="form",
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ from tildes.views.decorators import use_kwargs
|
||||
|
||||
@view_config(route_name="bookmarks", renderer="bookmarks.jinja2")
|
||||
@use_kwargs(PaginatedListingSchema())
|
||||
@use_kwargs({"post_type": PostType(data_key="type", missing="topic")})
|
||||
@use_kwargs({"post_type": PostType(data_key="type", load_default="topic")})
|
||||
def get_bookmarks(
|
||||
request: Request,
|
||||
after: Optional[str],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""Contains decorators for view functions."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Union
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import EXCLUDE
|
||||
from marshmallow.fields import Field
|
||||
@@ -16,7 +16,7 @@ from webargs import pyramidparser
|
||||
|
||||
|
||||
def use_kwargs(
|
||||
argmap: Union[Schema, dict[str, Field]], location: str = "query", **kwargs: Any
|
||||
argmap: Schema | dict[str, Field], location: str = "query", **kwargs: Any
|
||||
) -> Callable:
|
||||
"""Wrap the webargs @use_kwargs decorator with preferred default modifications.
|
||||
|
||||
@@ -36,7 +36,7 @@ def use_kwargs(
|
||||
|
||||
argmap.unknown = EXCLUDE
|
||||
|
||||
return pyramidparser.use_kwargs(argmap, location=location, **kwargs)
|
||||
return pyramidparser.use_kwargs(argmap, location=location, unknown=None, **kwargs)
|
||||
|
||||
|
||||
def ic_view_config(**kwargs: Any) -> Callable:
|
||||
|
||||
@@ -26,7 +26,7 @@ from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs
|
||||
@view_config(
|
||||
route_name="login", renderer="login.jinja2", permission=NO_PERMISSION_REQUIRED
|
||||
)
|
||||
@use_kwargs({"from_url": String(missing="")})
|
||||
@use_kwargs({"from_url": String(load_default="")})
|
||||
@not_logged_in
|
||||
def get_login(request: Request, from_url: str) -> dict:
|
||||
"""Display the login form."""
|
||||
@@ -65,7 +65,7 @@ def finish_login(request: Request, user: User, redirect_url: str) -> HTTPFound:
|
||||
),
|
||||
location="form",
|
||||
)
|
||||
@use_kwargs({"from_url": String(missing="")}, location="form")
|
||||
@use_kwargs({"from_url": String(load_default="")}, location="form")
|
||||
@not_logged_in
|
||||
@rate_limit_view("login")
|
||||
def post_login(
|
||||
@@ -148,7 +148,8 @@ def post_login(
|
||||
@not_logged_in
|
||||
@rate_limit_view("login_two_factor")
|
||||
@use_kwargs(
|
||||
{"code": String(missing=""), "from_url": String(missing="")}, location="form"
|
||||
{"code": String(load_default=""), "from_url": String(load_default="")},
|
||||
location="form",
|
||||
)
|
||||
def post_login_two_factor(request: Request, code: str, from_url: str) -> NoReturn:
|
||||
"""Process a log in request with 2FA."""
|
||||
|
||||
@@ -18,7 +18,7 @@ from tildes.views.decorators import use_kwargs
|
||||
@view_config(
|
||||
route_name="new_message", renderer="new_message.jinja2", permission="message"
|
||||
)
|
||||
@use_kwargs({"subject": String(missing=""), "message": String(missing="")})
|
||||
@use_kwargs({"subject": String(load_default=""), "message": String(load_default="")})
|
||||
def get_new_message_form(request: Request, subject: str, message: str) -> dict:
|
||||
"""Form for entering a new private message to send."""
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,7 @@ from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs
|
||||
@view_config(
|
||||
route_name="register", renderer="register.jinja2", permission=NO_PERMISSION_REQUIRED
|
||||
)
|
||||
@use_kwargs({"code": String(missing="")})
|
||||
@use_kwargs({"code": String(load_default="")})
|
||||
@not_logged_in
|
||||
def get_register(request: Request, code: str) -> dict:
|
||||
"""Display the registration form."""
|
||||
|
||||
@@ -22,6 +22,7 @@ from tildes.models.comment import Comment, CommentLabel, CommentTree
|
||||
from tildes.models.group import Group
|
||||
from tildes.models.topic import Topic
|
||||
from tildes.models.user import User
|
||||
from tildes.schemas.context import TildesSchemaContextDict, TildesSchemaContext
|
||||
from tildes.schemas.user import (
|
||||
BIO_MAX_LENGTH,
|
||||
EMAIL_ADDRESS_NOTE_MAX_LENGTH,
|
||||
@@ -151,12 +152,13 @@ def post_settings_password_change(
|
||||
) -> Response:
|
||||
"""Change the logged-in user's password."""
|
||||
# enable checking the new password against the breached-passwords list
|
||||
request.user.schema.context["check_breached_passwords"] = True
|
||||
context: TildesSchemaContextDict = {"check_breached_passwords": True}
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise HTTPUnprocessableEntity("New password and confirmation do not match.")
|
||||
|
||||
request.user.change_password(old_password, new_password)
|
||||
with TildesSchemaContext(context):
|
||||
request.user.change_password(old_password, new_password)
|
||||
|
||||
return Response("Your password has been updated")
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ DefaultSettings = namedtuple("DefaultSettings", ["order", "period"])
|
||||
@view_config(route_name="group_topics", request_method="POST", permission="topic.post")
|
||||
@use_kwargs(TopicSchema(only=("title", "markdown", "link")), location="form")
|
||||
@use_kwargs(
|
||||
{"tags": String(missing=""), "confirm_repost": Boolean(missing=False)},
|
||||
{"tags": String(load_default=""), "confirm_repost": Boolean(load_default=False)},
|
||||
location="form",
|
||||
)
|
||||
def post_group_topics(
|
||||
@@ -345,7 +345,7 @@ def get_group_topics( # noqa
|
||||
@view_config(route_name="search", renderer="search.jinja2")
|
||||
@view_config(route_name="group_search", renderer="search.jinja2")
|
||||
@use_kwargs(TopicListingSchema(only=("after", "before", "order", "per_page", "period")))
|
||||
@use_kwargs({"search": String(data_key="q", missing="")})
|
||||
@use_kwargs({"search": String(data_key="q", load_default="")})
|
||||
def get_search(
|
||||
request: Request,
|
||||
order: Optional[TopicSortOption],
|
||||
@@ -414,7 +414,7 @@ def get_search(
|
||||
@view_config(
|
||||
route_name="new_topic", renderer="new_topic.jinja2", permission="topic.post"
|
||||
)
|
||||
@use_kwargs({"title": String(missing=""), "link": String(missing="")})
|
||||
@use_kwargs({"title": String(load_default=""), "link": String(load_default="")})
|
||||
def get_new_topic_form(request: Request, title: str, link: str) -> dict:
|
||||
"""Form for entering a new topic to post."""
|
||||
group = request.context
|
||||
@@ -424,7 +424,7 @@ def get_new_topic_form(request: Request, title: str, link: str) -> dict:
|
||||
|
||||
@view_config(route_name="topic", renderer="topic.jinja2")
|
||||
@view_config(route_name="topic_no_title", renderer="topic.jinja2")
|
||||
@use_kwargs({"comment_order": Enum(CommentTreeSortOption, missing=None)})
|
||||
@use_kwargs({"comment_order": Enum(CommentTreeSortOption, load_default=None)})
|
||||
def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict:
|
||||
"""View a single topic."""
|
||||
topic = request.context
|
||||
|
||||
@@ -25,8 +25,8 @@ from tildes.views.decorators import use_kwargs
|
||||
@use_kwargs(MixedListingSchema())
|
||||
@use_kwargs(
|
||||
{
|
||||
"post_type": PostType(data_key="type", missing=None),
|
||||
"order_name": String(data_key="order", missing="new"),
|
||||
"post_type": PostType(data_key="type", load_default=None),
|
||||
"order_name": String(data_key="order", load_default="new"),
|
||||
}
|
||||
)
|
||||
def get_user(
|
||||
@@ -94,8 +94,8 @@ def get_user(
|
||||
@use_kwargs(
|
||||
{
|
||||
"post_type": PostType(data_key="type", required=True),
|
||||
"order_name": String(data_key="order", missing="new"),
|
||||
"search": String(data_key="q", missing=""),
|
||||
"order_name": String(data_key="order", load_default="new"),
|
||||
"search": String(data_key="q", load_default=""),
|
||||
}
|
||||
)
|
||||
def get_user_search(
|
||||
|
||||
@@ -15,7 +15,7 @@ from tildes.views.decorators import use_kwargs
|
||||
|
||||
@view_config(route_name="votes", renderer="votes.jinja2")
|
||||
@use_kwargs(PaginatedListingSchema())
|
||||
@use_kwargs({"post_type": PostType(data_key="type", missing="topic")})
|
||||
@use_kwargs({"post_type": PostType(data_key="type", load_default="topic")})
|
||||
def get_voted_posts(
|
||||
request: Request,
|
||||
after: Optional[str],
|
||||
|
||||
Reference in New Issue
Block a user