Browse Source

Add comments endpoint

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

138
tildes/openapi_beta.yaml

@ -57,6 +57,44 @@ paths:
$ref: '#/components/schemas/Pagination'
"400":
$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:
@ -67,7 +105,7 @@ components:
schema:
type: string
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:
in: query
@ -119,9 +157,10 @@ components:
- ignored
- official
- tags
- last_visit_time
properties:
id:
type: integer
type: string
title:
type: string
text_html:
@ -170,87 +209,82 @@ components:
type: array
items:
type: string
last_visit_time:
type: string
nullable: true
Comment:
type: object
required:
- id
- depth
- author
- group
- topic_id
- voted
- can_vote
- can_label
- can_edit
- can_reply
- body_html
- author
- rendered_html
- created_at
- edited_at
- votes
- posted_datetime
- posted_datetime_abbreviated
- deleted
- removed
- is_removed
- is_deleted
- exemplary
- collapsed
- collapsed_individual
- exemplary
- exemplary_reasons
- user_labels
- is_op
- is_me
- is_new
- is_by_op
- voted
- bookmarked
- depth
- children
properties:
id:
type: string
depth:
type: integer
author:
topic_id:
type: string
parent_author:
author:
type: string
nullable: true
group:
rendered_html:
type: string
topic_id:
nullable: true
created_at:
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
nullable: true
votes:
type: integer
posted_datetime:
type: string
posted_datetime_abbreviated:
type: string
deleted:
is_removed:
type: boolean
is_deleted:
type: boolean
removed:
exemplary:
type: boolean
nullable: true
collapsed:
type: boolean
collapsed_individual:
type: boolean
exemplary:
is_op:
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:
type: boolean
is_by_op:
nullable: true
voted:
type: boolean
nullable: true
bookmarked:
type: boolean
nullable: true
depth:
type: integer
children:
type: array
items:
$ref: '#/components/schemas/Comment'
Pagination:
type: object

2
tildes/tildes/lib/database.py

@ -179,7 +179,7 @@ class RecurrenceRule(TypeDecorator):
if value is None:
return value
return rrulestr(value) # type: ignore
return rrulestr(value) # type: ignore
class TagList(TypeDecorator):

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_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:

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."""
from marshmallow.exceptions import ValidationError
from pyramid.exceptions import HTTPBadRequest
from pyramid.request import Request
from pyramid.response import Response
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.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")
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")
period_raw = request.openapi_validated.parameters.query.get("period")
tag = request.openapi_validated.parameters.query.get("tag", None)
@ -91,25 +180,7 @@ def get_topics(request: Request) -> dict:
# Build the JSON topic data
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)
# Construct the paging next and previous link if there are more topics
@ -139,3 +210,78 @@ def get_topics(request: Request) -> dict:
},
}
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