class TopicListingSchema(Schema): """Marshmallow schema to validate arguments for a topic listing page.""" DEFAULT_TOPICS_PER_PAGE = 50 order = Enum(TopicSortOption) period = ShortTimePeriod(allow_none=True) after = ID36() before = ID36() per_page = Integer( validate=Range(min=1, max=100), missing=DEFAULT_TOPICS_PER_PAGE, ) rank_start = Integer(load_from='n', validate=Range(min=1), missing=None) tag = Ltree(missing=None) unfiltered = Boolean(missing=False) @validates_schema def either_after_or_before(self, data: dict) -> None: """Fail validation if both after and before were specified.""" if data.get('after') and data.get('before'): raise ValidationError("Can't specify both after and before.") @pre_load def reset_rank_start_on_first_page(self, data: dict) -> dict: """Reset rank_start to 1 if this is a first page (no before/after).""" if not (data.get('before') or data.get('after')): data['rank_start'] = 1 return data class Meta: """Always use strict checking so error handlers are invoked.""" strict = True
class CommentLabelSchema(Schema): """Marshmallow schema for comment labels.""" name = Enum(CommentLabelOption) reason = SimpleString(max_length=1000, missing=None) class Meta: """Always use strict checking so error handlers are invoked.""" strict = True
class TopicListingSchema(PaginatedListingSchema): """Marshmallow schema to validate arguments for a topic listing page.""" period = ShortTimePeriod(allow_none=True) order = Enum(TopicSortOption) tag = Ltree(missing=None) unfiltered = Boolean(missing=False) rank_start = Integer(load_from="n", validate=Range(min=1), missing=None) @pre_load def reset_rank_start_on_first_page(self, data: dict) -> dict: """Reset rank_start to 1 if this is a first page (no before/after).""" if not (data.get("before") or data.get("after")): data["rank_start"] = 1 return data
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) rank_start = Integer(data_key="n", validate=Range(min=1), missing=None) @pre_load def reset_rank_start_on_first_page( self, data: dict, many: bool, partial: Any ) -> dict: """Reset rank_start to 1 if this is a first page (no before/after).""" # pylint: disable=unused-argument if "rank_start" not in self.fields: return data if not (data.get("before") or data.get("after")): data["n"] = 1 return data
class TopicSchema(Schema): """Marshmallow schema for topics.""" topic_id36 = ID36() title = SimpleString(max_length=TITLE_MAX_LENGTH) topic_type = Enum(dump_only=True) markdown = Markdown(allow_none=True) rendered_html = String(dump_only=True) link = URL(schemes={"http", "https"}, allow_none=True) created_time = DateTime(dump_only=True) tags = List(String()) user = Nested(UserSchema, dump_only=True) group = Nested(GroupSchema, dump_only=True) @pre_load def prepare_title(self, data: dict, many: bool, partial: Any) -> dict: """Prepare the title before it's validated.""" # pylint: disable=unused-argument if "title" not in data: return data new_data = data.copy() split_title = re.split("[.?!]+", new_data["title"]) # the last string in the list will be empty if it ended with punctuation num_sentences = len([piece for piece in split_title if piece]) # strip trailing periods off single-sentence titles if num_sentences == 1: new_data["title"] = new_data["title"].rstrip(".") return new_data @pre_load def prepare_tags(self, data: dict, many: bool, partial: Any) -> dict: """Prepare the tags before they're validated.""" # pylint: disable=unused-argument if "tags" not in data: return data new_data = data.copy() tags: list[str] = [] for tag in new_data["tags"]: tag = tag.lower() # replace underscores with spaces tag = tag.replace("_", " ") # remove any consecutive spaces tag = re.sub(" {2,}", " ", tag) # remove any leading/trailing spaces tag = tag.strip(" ") # drop any empty tags if not tag or tag.isspace(): continue # handle synonyms for name, synonyms in TAG_SYNONYMS.items(): if tag in synonyms: tag = name # skip any duplicate tags if tag in tags: continue tags.append(tag) new_data["tags"] = tags return new_data @validates("tags") def validate_tags(self, value: list[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 path. This is definitely mixing concerns, but it's deliberate in this case. It will allow for some interesting possibilities by ensuring naming "compatibility" between groups and tags. For example, a popular tag in a group could be converted into a sub-group easily. """ group_schema = GroupSchema(partial=True) for tag in value: try: group_schema.load({"path": tag}) except ValidationError as exc: raise ValidationError("Tag %s is invalid" % tag) from exc @pre_load def prepare_markdown(self, data: dict, many: bool, partial: Any) -> dict: """Prepare the markdown value before it's validated.""" # pylint: disable=unused-argument if "markdown" not in data: return data new_data = data.copy() # if the value is empty, convert it to None if not new_data["markdown"] or new_data["markdown"].isspace(): new_data["markdown"] = None return new_data @pre_load def prepare_link(self, data: dict, many: bool, partial: Any) -> dict: """Prepare the link value before it's validated.""" # pylint: disable=unused-argument if "link" not in data: return data new_data = data.copy() # remove leading/trailing whitespace new_data["link"] = new_data["link"].strip() # if the value is empty, convert it to None if not new_data["link"]: new_data["link"] = None return new_data # prepend http:// to the link if it doesn't have a scheme parsed = urlparse(new_data["link"]) if not parsed.scheme: new_data["link"] = "http://" + new_data["link"] # run the link through the url-transformation process new_data["link"] = apply_url_transformations(new_data["link"]) return new_data @validates_schema def link_or_markdown(self, data: dict, many: bool, partial: Any) -> 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: return link = data.get("link") markdown = data.get("markdown") if not (markdown or link): raise ValidationError("Topics must have either markdown or a link.")
@view_config(route_name="new_topic", renderer="new_topic.jinja2", permission="post_topic") def get_new_topic_form(request: Request) -> dict: """Form for entering a new topic to post.""" group = request.context return {"group": group} @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="relevance")}) def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict: """View a single topic.""" topic = request.context # deleted and removed comments need to be included since they're necessary for # building the tree if they have replies comments = ( request.query(Comment).include_deleted().include_removed().filter( Comment.topic == topic).order_by(Comment.created_time).all()) tree = CommentTree(comments, comment_order, request.user) # check if there are any items in the log to show visible_events = ( LogEventType.TOPIC_LINK_EDIT, LogEventType.TOPIC_LOCK,
# doing an atomic decrement on request.user.invite_codes_remaining is going to make # it unusable as an integer in the template, so store the expected value after the # decrement first, to be able to use that instead num_remaining = request.user.invite_codes_remaining - 1 request.user.invite_codes_remaining = User.invite_codes_remaining - 1 return {"code": code, "num_remaining": num_remaining} @ic_view_config( route_name="user_default_listing_options", request_method="PUT", permission="edit_default_listing_options", ) @use_kwargs( {"order": Enum(TopicSortOption), "period": ShortTimePeriod(allow_none=True)} ) def put_default_listing_options( request: Request, order: TopicSortOption, period: Optional[ShortTimePeriod] ) -> dict: """Set the user's default listing options.""" user = request.context user.home_default_order = order if period: user.home_default_period = period.as_short_form() else: user.home_default_period = "all" return IC_NOOP
GroupSubscription.user == request.user).delete( synchronize_session=False) # manually commit the transaction so triggers will execute request.tm.commit() # re-query the group to get complete data group = (request.query(Group).join_all_relationships().filter_by( group_id=group.group_id).one()) return {"group": group} @ic_view_config(route_name="group_user_settings", request_method="PATCH") @use_kwargs({ "order": Enum(TopicSortOption), "period": ShortTimePeriod(allow_none=True) }) def patch_group_user_settings(request: Request, order: TopicSortOption, period: Optional[ShortTimePeriod]) -> dict: """Set the user's default listing options.""" if period: default_period = period.as_short_form() else: default_period = "all" statement = (insert(UserGroupSettings.__table__).values( user_id=request.user.user_id, group_id=request.context.group_id, default_order=order, default_period=default_period,
# manually commit the transaction so triggers will execute request.tm.commit() # re-query the group to get complete data group = (request.query(Group).join_all_relationships().filter_by( group_id=group.group_id).one()) return {'group': group} @ic_view_config( route_name='group_user_settings', request_method='PATCH', ) @use_kwargs({ 'order': Enum(TopicSortOption), 'period': ShortTimePeriod(allow_none=True), }) def patch_group_user_settings( request: Request, order: TopicSortOption, period: Optional[ShortTimePeriod], ) -> dict: """Set the user's default listing options.""" if period: default_period = period.as_short_form() else: default_period = 'all' statement = (insert(UserGroupSettings.__table__).values( user_id=request.user.user_id,
class CommentLabelSchema(Schema): """Marshmallow schema for comment labels.""" name = Enum(CommentLabelOption) reason = SimpleString(max_length=1000, missing=None)
class TopicSchema(Schema): """Marshmallow schema for topics.""" topic_id36 = ID36() title = SimpleString(max_length=TITLE_MAX_LENGTH) topic_type = Enum(dump_only=True) markdown = Markdown(allow_none=True) rendered_html = String(dump_only=True) link = URL(schemes={'http', 'https'}, allow_none=True) created_time = DateTime(dump_only=True) tags = List(Ltree()) user = Nested(UserSchema, dump_only=True) group = Nested(GroupSchema, dump_only=True) @pre_load def prepare_tags(self, data: dict) -> dict: """Prepare the tags before they're validated.""" if 'tags' not in data: return data tags: typing.List[str] = [] for tag in data['tags']: tag = tag.lower() # replace spaces with underscores tag = tag.replace(' ', '_') # remove any consecutive underscores tag = re.sub('_{2,}', '_', tag) # remove any leading/trailing underscores tag = tag.strip('_') # drop any empty tags if not tag or tag.isspace(): continue # skip any duplicate tags if tag in tags: continue tags.append(tag) data['tags'] = tags return data @validates('tags') def validate_tags( self, value: typing.List[sqlalchemy_utils.Ltree], ) -> 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 path. This is definitely mixing concerns, but it's deliberate in this case. It will allow for some interesting possibilities by ensuring naming "compatibility" between groups and tags. For example, a popular tag in a group could be converted into a sub-group easily. """ group_schema = GroupSchema(partial=True) for tag in value: try: group_schema.validate({'path': tag}) except ValidationError: raise ValidationError('Tag %s is invalid' % tag) @pre_load def prepare_markdown(self, data: dict) -> dict: """Prepare the markdown value before it's validated.""" if 'markdown' not in data: return data # if the value is empty, convert it to None if not data['markdown'] or data['markdown'].isspace(): data['markdown'] = None return data @pre_load def prepare_link(self, data: dict) -> dict: """Prepare the link value before it's validated.""" if 'link' not in data: return data # if the value is empty, convert it to None if not data['link'] or data['link'].isspace(): data['link'] = None return data # prepend http:// to the link if it doesn't have a scheme parsed = urlparse(data['link']) if not parsed.scheme: data['link'] = 'http://' + data['link'] return data @validates_schema def link_or_markdown(self, data: dict) -> None: """Fail validation unless at least one of link or markdown were set.""" if 'link' not in data and 'markdown' not in data: return link = data.get('link') markdown = data.get('markdown') if not (markdown or link): raise ValidationError( 'Topics must have either markdown or a link.') class Meta: """Always use strict checking so error handlers are invoked.""" strict = True
class TopicSchema(Schema): """Marshmallow schema for topics.""" topic_id36 = ID36() title = SimpleString(max_length=TITLE_MAX_LENGTH) topic_type = Enum(dump_only=True) markdown = Markdown(allow_none=True) rendered_html = String(dump_only=True) link = URL(schemes={"http", "https"}, allow_none=True) created_time = DateTime(dump_only=True) tags = List(Ltree()) user = Nested(UserSchema, dump_only=True) group = Nested(GroupSchema, dump_only=True) @pre_load def prepare_tags(self, data: dict) -> dict: """Prepare the tags before they're validated.""" if "tags" not in data: return data tags: typing.List[str] = [] for tag in data["tags"]: tag = tag.lower() # replace spaces with underscores tag = tag.replace(" ", "_") # remove any consecutive underscores tag = re.sub("_{2,}", "_", tag) # remove any leading/trailing underscores tag = tag.strip("_") # drop any empty tags if not tag or tag.isspace(): continue # handle synonyms for name, synonyms in TAG_SYNONYMS.items(): if tag in synonyms: tag = name # skip any duplicate tags if tag in tags: continue tags.append(tag) data["tags"] = tags return data @validates("tags") def validate_tags(self, value: typing.List[sqlalchemy_utils.Ltree]) -> 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 path. This is definitely mixing concerns, but it's deliberate in this case. It will allow for some interesting possibilities by ensuring naming "compatibility" between groups and tags. For example, a popular tag in a group could be converted into a sub-group easily. """ group_schema = GroupSchema(partial=True) for tag in value: try: group_schema.validate({"path": str(tag)}) except ValidationError: raise ValidationError("Tag %s is invalid" % tag) @pre_load def prepare_markdown(self, data: dict) -> dict: """Prepare the markdown value before it's validated.""" if "markdown" not in data: return data # if the value is empty, convert it to None if not data["markdown"] or data["markdown"].isspace(): data["markdown"] = None return data @pre_load def prepare_link(self, data: dict) -> dict: """Prepare the link value before it's validated.""" if "link" not in data: return data # if the value is empty, convert it to None if not data["link"] or data["link"].isspace(): data["link"] = None return data # prepend http:// to the link if it doesn't have a scheme parsed = urlparse(data["link"]) if not parsed.scheme: data["link"] = "http://" + data["link"] # run the link through the url-transformation process data["link"] = apply_url_transformations(data["link"]) return data @validates_schema def link_or_markdown(self, data: dict) -> None: """Fail validation unless at least one of link or markdown were set.""" if "link" not in data and "markdown" not in data: return link = data.get("link") markdown = data.get("markdown") if not (markdown or link): raise ValidationError("Topics must have either markdown or a link.") class Meta: """Always use strict checking so error handlers are invoked.""" strict = True
} @view_config( route_name="new_topic", renderer="new_topic.jinja2", permission="post_topic" ) def get_new_topic_form(request: Request) -> dict: """Form for entering a new topic to post.""" group = request.context return {"group": group} @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)}) def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict: """View a single topic.""" topic = request.context if comment_order is None: if request.user and request.user.comment_sort_order_default: comment_order = request.user.comment_sort_order_default else: comment_order = CommentTreeSortOption.RELEVANCE # deleted and removed comments need to be included since they're necessary for # building the tree if they have replies comments = ( request.query(Comment) .include_deleted() .include_removed()
@view_config( route_name='new_topic', renderer='new_topic.jinja2', permission='post_topic', ) def get_new_topic_form(request: Request) -> dict: """Form for entering a new topic to post.""" group = request.context return {'group': group} @view_config(route_name='topic', renderer='topic.jinja2') @use_kwargs({ 'comment_order': Enum(CommentSortOption, missing='votes'), }) def get_topic(request: Request, comment_order: CommentSortOption) -> dict: """View a single topic.""" topic = request.context # deleted and removed comments need to be included since they're necessary # for building the tree if they have replies comments = ( request.query(Comment).include_deleted().include_removed().filter( Comment.topic == topic).order_by(Comment.created_time).all()) tree = CommentTree(comments, comment_order) # check if there are any items in the log to show visible_events = ( LogEventType.TOPIC_LOCK,