From e6b8a1098d3c73eaf3f691935355aa274c2b0d9b Mon Sep 17 00:00:00 2001 From: pollev Date: Wed, 13 Aug 2025 15:58:05 +0200 Subject: [PATCH] Add comments endpoint --- tildes/openapi_beta.yaml | 138 ++++++++++------ tildes/tildes/lib/database.py | 2 +- tildes/tildes/routes.py | 4 +- tildes/tildes/views/api/beta/__init__.py | 2 +- tildes/tildes/views/api/beta/topic.py | 192 ++++++++++++++++++++--- 5 files changed, 260 insertions(+), 78 deletions(-) diff --git a/tildes/openapi_beta.yaml b/tildes/openapi_beta.yaml index 2a6d3a2..abc2e5b 100644 --- a/tildes/openapi_beta.yaml +++ b/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 diff --git a/tildes/tildes/lib/database.py b/tildes/tildes/lib/database.py index db1c29c..b28e397 100644 --- a/tildes/tildes/lib/database.py +++ b/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): diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index d27b3ef..4425399 100644 --- a/tildes/tildes/routes.py +++ b/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: diff --git a/tildes/tildes/views/api/beta/__init__.py b/tildes/tildes/views/api/beta/__init__.py index 9e7a664..a518b4a 100644 --- a/tildes/tildes/views/api/beta/__init__.py +++ b/tildes/tildes/views/api/beta/__init__.py @@ -1 +1 @@ -"""Contains views for the JSON web API""" +"""Contains views for the JSON web API.""" diff --git a/tildes/tildes/views/api/beta/topic.py b/tildes/tildes/views/api/beta/topic.py index ec7d6b4..cb74a1a 100644 --- a/tildes/tildes/views/api/beta/topic.py +++ b/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