Browse Source

Add comments endpoint

merge-requests/160/head
pollev 4 months ago
parent
commit
e6b8a1098d
  1. 138
      tildes/openapi_beta.yaml
  2. 4
      tildes/tildes/routes.py
  3. 2
      tildes/tildes/views/api/beta/__init__.py
  4. 192
      tildes/tildes/views/api/beta/topic.py

138
tildes/openapi_beta.yaml

@ -57,6 +57,44 @@ paths:
$ref: '#/components/schemas/Pagination' $ref: '#/components/schemas/Pagination'
"400": "400":
$ref: "#/components/responses/ValidationError" $ref: "#/components/responses/ValidationError"
/topic/{topic_id36}:
get:
summary: Get a single topic and its comments
parameters:
- in: path
name: topic_id36
schema:
type: string
required: true
description: The ID36 of the topic to retrieve.
- in: query
name: order
schema:
type: string
default: "relevance"
enum: ["votes", "newest", "posted", "relevance"]
required: false
description: The sort order for the comment tree. Defaults to "relevance".
responses:
"200":
description: A single topic and its comments
content:
application/json:
schema:
type: object
required:
- topic
- comments
properties:
topic:
$ref: '#/components/schemas/Topic'
comments:
type: array
items:
$ref: '#/components/schemas/Comment'
"400":
$ref: "#/components/responses/ValidationError"
components: components:
@ -67,7 +105,7 @@ components:
schema: schema:
type: string type: string
required: false required: false
description: The ID36 of the first item from the previous page, to get items before it. You can only specify either `before` or `after`, not both.
description: The ID36 of the first item from the current page, to get items before it. You can only specify either `before` or `after`, not both.
paginationAfter: paginationAfter:
in: query in: query
@ -119,9 +157,10 @@ components:
- ignored - ignored
- official - official
- tags - tags
- last_visit_time
properties: properties:
id: id:
type: integer
type: string
title: title:
type: string type: string
text_html: text_html:
@ -170,87 +209,82 @@ components:
type: array type: array
items: items:
type: string type: string
last_visit_time:
type: string
nullable: true
Comment: Comment:
type: object type: object
required: required:
- id - id
- depth
- author
- group
- topic_id - topic_id
- voted
- can_vote
- can_label
- can_edit
- can_reply
- body_html
- author
- rendered_html
- created_at
- edited_at
- votes - votes
- posted_datetime
- posted_datetime_abbreviated
- deleted
- removed
- is_removed
- is_deleted
- exemplary
- collapsed - collapsed
- collapsed_individual - collapsed_individual
- exemplary
- exemplary_reasons
- user_labels
- is_op
- is_me
- is_new - is_new
- is_by_op
- voted
- bookmarked
- depth
- children
properties: properties:
id: id:
type: string type: string
depth:
type: integer
author:
topic_id:
type: string type: string
parent_author:
author:
type: string type: string
nullable: true nullable: true
group:
rendered_html:
type: string type: string
topic_id:
nullable: true
created_at:
type: string type: string
voted:
type: boolean
can_vote:
type: boolean
can_label:
type: boolean
can_edit:
type: boolean
can_reply:
type: boolean
body_html:
edited_at:
type: string type: string
nullable: true
votes: votes:
type: integer type: integer
posted_datetime:
type: string
posted_datetime_abbreviated:
type: string
deleted:
is_removed:
type: boolean
is_deleted:
type: boolean type: boolean
removed:
exemplary:
type: boolean type: boolean
nullable: true
collapsed: collapsed:
type: boolean type: boolean
collapsed_individual: collapsed_individual:
type: boolean type: boolean
exemplary:
is_op:
type: boolean type: boolean
exemplary_reasons:
type: array
items:
type: string
user_labels:
type: array
items:
type: string
nullable: true
is_me:
type: boolean
nullable: true
is_new: is_new:
type: boolean type: boolean
is_by_op:
nullable: true
voted:
type: boolean type: boolean
nullable: true
bookmarked:
type: boolean
nullable: true
depth:
type: integer
children:
type: array
items:
$ref: '#/components/schemas/Comment'
Pagination: Pagination:
type: object type: object

4
tildes/tildes/routes.py

@ -133,7 +133,9 @@ def includeme(config: Configurator) -> None:
config.pyramid_openapi3_spec("openapi_beta.yaml", route="/api/beta/openapi.yaml") config.pyramid_openapi3_spec("openapi_beta.yaml", route="/api/beta/openapi.yaml")
config.pyramid_openapi3_add_explorer(route="/api/beta/ui") config.pyramid_openapi3_add_explorer(route="/api/beta/ui")
config.add_route("apibeta.topics", "/api/beta/topics")
with config.route_prefix_context("/api/beta"):
config.add_route("apibeta.topics", "/topics")
config.add_route("apibeta.topic", "/topic/{topic_id36}")
def add_intercooler_routes(config: Configurator) -> None: def add_intercooler_routes(config: Configurator) -> None:

2
tildes/tildes/views/api/beta/__init__.py

@ -1 +1 @@
"""Contains views for the JSON web API"""
"""Contains views for the JSON web API."""

192
tildes/tildes/views/api/beta/topic.py

@ -4,18 +4,107 @@
"""JSON API endpoints related to topics.""" """JSON API endpoints related to topics."""
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
from pyramid.exceptions import HTTPBadRequest
from pyramid.request import Request 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 tildes.enums import TopicSortOption
from tildes.enums import CommentTreeSortOption, TopicSortOption
from tildes.models.comment import CommentTree, Comment
from tildes.models.comment.comment_tree import CommentInTree
from tildes.models.topic import Topic from tildes.models.topic import Topic
from tildes.schemas.fields import ShortTimePeriod from tildes.schemas.fields import ShortTimePeriod
from tildes.lib.id import id36_to_id
def _topic_to_dict(topic: Topic) -> dict:
"""Convert a Topic object to a dictionary for JSON serialization."""
return {
"id": topic.topic_id36,
"title": topic.title,
"text_html": topic.rendered_html,
"url": topic.link,
"comments_url": topic.permalink,
"group": str(topic.group.path),
"content_metadata": topic.content_metadata_for_display,
"created_at": topic.created_time.isoformat(),
"posted_by_user": topic.user.username,
"vote_count": topic.num_votes,
"comment_count": topic.num_comments,
"new_comment_count": topic.comments_since_last_visit,
"voted": topic.user_voted,
"bookmarked": topic.user_bookmarked,
"ignored": topic.user_ignored,
"official": topic.is_official,
"tags": topic.tags,
"last_visit_time": (
topic.last_visit_time.isoformat() if topic.last_visit_time else None
),
}
def _comment_to_dict(request: Request, comment: Comment) -> dict:
"""Convert a Comment object to a dictionary for JSON serialization."""
# Check permissions for viewing comment details (and set safe defaults)
author = None
rendered_html = None
exemplary = None
is_op = None
is_me = None
is_new = None
if request.has_permission("view", comment):
author = comment.user.username
rendered_html = comment.rendered_html
exemplary = comment.is_label_active("exemplary")
is_me = request.user == comment.user if request.user else False
if request.has_permission("view_author", comment.topic):
is_op = comment.user == comment.topic.user
is_new = (
(comment.created_time > comment.topic.last_visit_time)
if (comment.topic.last_visit_time and not is_me)
else False
)
return {
"id": comment.comment_id36,
"topic_id": comment.topic.topic_id36,
"author": author,
"rendered_html": rendered_html,
"created_at": comment.created_time.isoformat(),
"edited_at": (
comment.last_edited_time.isoformat() if comment.last_edited_time else None
),
"votes": comment.num_votes,
"is_removed": comment.is_removed,
"is_deleted": comment.is_deleted,
"exemplary": exemplary,
"collapsed": (comment.collapsed_state == "full"),
"collapsed_individual": (comment.collapsed_state == "individual"),
"is_op": is_op,
"is_me": is_me,
"is_new": is_new,
"voted": comment.user_voted,
"bookmarked": comment.user_bookmarked,
}
def _comment_subtree_to_dict(request: Request, comments: list[CommentInTree]) -> list:
"""Convert a comment subtree to a list of dictionaries for JSON serialization."""
comments_list = []
for comment in comments:
comment_dict = _comment_to_dict(request, comment)
comment_dict["depth"] = comment.depth
comment_dict["children"] = (
_comment_subtree_to_dict(request, comment.replies)
if comment.replies
else []
)
comments_list.append(comment_dict)
return comments_list
@view_config(route_name="apibeta.topics", openapi=True, renderer="json") @view_config(route_name="apibeta.topics", openapi=True, renderer="json")
def get_topics(request: Request) -> dict:
"""Get topics"""
def get_topics(request: Request) -> dict: # noqa
"""Get a list of topics (without comments)."""
limit = request.openapi_validated.parameters.query.get("limit") limit = request.openapi_validated.parameters.query.get("limit")
period_raw = request.openapi_validated.parameters.query.get("period") period_raw = request.openapi_validated.parameters.query.get("period")
tag = request.openapi_validated.parameters.query.get("tag", None) tag = request.openapi_validated.parameters.query.get("tag", None)
@ -91,25 +180,7 @@ def get_topics(request: Request) -> dict:
# Build the JSON topic data # Build the JSON topic data
for topic in topics: for topic in topics:
processed_topic = {
"id": topic.topic_id,
"title": topic.title,
"text_html": topic.rendered_html,
"url": topic.link,
"comments_url": topic.permalink,
"group": str(topic.group.path),
"content_metadata": topic.content_metadata_for_display,
"created_at": topic.created_time.isoformat(),
"posted_by_user": topic.user.username,
"vote_count": topic.num_votes,
"comment_count": topic.num_comments,
"new_comment_count": topic.comments_since_last_visit,
"voted": topic.user_voted,
"bookmarked": topic.user_bookmarked,
"ignored": topic.user_ignored,
"official": topic.is_official,
"tags": topic.tags,
}
processed_topic = _topic_to_dict(topic)
processed_topics.append(processed_topic) processed_topics.append(processed_topic)
# Construct the paging next and previous link if there are more topics # Construct the paging next and previous link if there are more topics
@ -139,3 +210,78 @@ def get_topics(request: Request) -> dict:
}, },
} }
return response return response
@view_config(route_name="apibeta.topic", openapi=True, renderer="json")
def get_topic(request: Request) -> dict:
"""Get a single topic (with comments)."""
topic_id36 = request.openapi_validated.parameters.path.get("topic_id36")
comment_order_raw = request.openapi_validated.parameters.query.get("order")
comment_order = CommentTreeSortOption.RELEVANCE
if comment_order_raw is not None:
try:
comment_order = CommentTreeSortOption[comment_order_raw.upper()]
except KeyError:
return Response(
status=400,
content_type="application/json",
json=[
{
"message": f"Invalid order value: {comment_order_raw}",
"field": "order",
"exception": "ValidationError",
}
],
)
else:
if request.user and request.user.comment_sort_order_default:
comment_order = request.user.comment_sort_order_default
else:
comment_order = CommentTreeSortOption.RELEVANCE
try:
topic_id = id36_to_id(topic_id36)
query = request.query(Topic).filter(Topic.topic_id == topic_id)
topic = query.one_or_none()
if not topic:
raise ValueError(f"Topic with ID {topic_id36} not found")
except ValueError as exc:
return Response(
status=400,
content_type="application/json",
json=[
{
"message": str(exc),
"field": "topic_id36",
"exception": "ValidationError",
}
],
)
# 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)
tree.collapse_from_labels()
if request.user:
# collapse old comments if the user has a previous visit to the topic
# (and doesn't have that behavior disabled)
if topic.last_visit_time and request.user.collapse_old_comments:
tree.uncollapse_new_comments(topic.last_visit_time)
tree.finalize_collapsing_maximized()
commentsjson = _comment_subtree_to_dict(request, tree.tree)
# Construct the final response JSON object
response = {"topic": _topic_to_dict(topic), "comments": commentsjson}
return response
Loading…
Cancel
Save