From 94339d5f9ff38699e28f5013c861f6968d075c79 Mon Sep 17 00:00:00 2001 From: pollev Date: Fri, 15 Aug 2025 10:02:55 +0200 Subject: [PATCH] add user endpoints --- tildes/openapi_beta.yaml | 116 +++++++++++++++ tildes/tildes/routes.py | 3 + tildes/tildes/views/api/beta/user.py | 215 +++++++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 tildes/tildes/views/api/beta/user.py diff --git a/tildes/openapi_beta.yaml b/tildes/openapi_beta.yaml index 74741a1..ebf5ead 100644 --- a/tildes/openapi_beta.yaml +++ b/tildes/openapi_beta.yaml @@ -93,10 +93,118 @@ paths: type: array items: $ref: '#/components/schemas/Comment' + "400": + $ref: "#/components/responses/ValidationError" + + /user/{username}: + get: + summary: Get a user along with their history + parameters: + - in: path + name: username + schema: + type: string + required: true + description: The username of the user to retrieve. + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + responses: + "200": + description: Basic user information and their post/comment history + content: + application/json: + schema: + type: object + required: + - user + - history + - pagination + properties: + user: + $ref: '#/components/schemas/User' + history: + type: array + items: + anyOf: + - $ref: '#/components/schemas/Topic' + - $ref: '#/components/schemas/Comment' + pagination: + $ref: '#/components/schemas/Pagination' + "400": + $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/AuthorizationError" + /user/{username}/comments: + get: + summary: Get comments made by a user + parameters: + - in: path + name: username + schema: + type: string + required: true + description: The username of the user for whom to retrieve comments. + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + responses: + "200": + description: A list of comments made by the user + content: + application/json: + schema: + type: object + required: + - comments + - pagination + properties: + comments: + type: array + items: + $ref: '#/components/schemas/Comment' + pagination: + $ref: '#/components/schemas/Pagination' "400": $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/AuthorizationError" + /user/{username}/topics: + get: + summary: Get topics made by a user + parameters: + - in: path + name: username + schema: + type: string + required: true + description: The username of the user for whom to retrieve topics. + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationBefore' + - $ref: '#/components/parameters/paginationAfter' + responses: + "200": + description: A list of topics made by the user + content: + application/json: + schema: + type: object + required: + - topics + - pagination + properties: + topics: + type: array + items: + $ref: '#/components/schemas/Topic' + pagination: + $ref: '#/components/schemas/Pagination' + "400": + $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/AuthorizationError" components: parameters: @@ -289,6 +397,14 @@ components: items: $ref: '#/components/schemas/Comment' + User: + type: object + required: + - username + properties: + username: + type: string + Pagination: type: object required: diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index 4425399..ea77bba 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -136,6 +136,9 @@ def includeme(config: Configurator) -> None: with config.route_prefix_context("/api/beta"): config.add_route("apibeta.topics", "/topics") config.add_route("apibeta.topic", "/topic/{topic_id36}") + config.add_route("apibeta.user", "/user/{username}") + config.add_route("apibeta.user_comments", "/user/{username}/comments") + config.add_route("apibeta.user_topics", "/user/{username}/topics") def add_intercooler_routes(config: Configurator) -> None: diff --git a/tildes/tildes/views/api/beta/user.py b/tildes/tildes/views/api/beta/user.py new file mode 100644 index 0000000..cdbdda7 --- /dev/null +++ b/tildes/tildes/views/api/beta/user.py @@ -0,0 +1,215 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API endpoints related to users.""" + +from pyramid.request import Request +from pyramid.view import view_config +from tildes.models.user.user import User +from tildes.models.comment import Comment +from tildes.models.pagination import MixedPaginatedResults +from tildes.models.topic import Topic +from tildes.views.api.beta.api_utils import ( + build_error_response, + get_next_and_prev_link, + query_apply_pagination, +) +from tildes.views.api.beta.comment import comment_to_dict +from tildes.views.api.beta.topic import topic_to_dict + + +def _user_to_dict(user: User) -> dict: + """Convert a User object to a dictionary for JSON serialization.""" + return { + "username": user.username, + "joined_at": user.created_time.isoformat(), + "bio_rendered_html": user.bio_rendered_html, + } + + +@view_config(route_name="apibeta.user", openapi=True, renderer="json") +def get_user(request: Request) -> dict: # noqa + """Get a single user with their comment and post history.""" + username = request.openapi_validated.parameters.path.get("username") + limit = request.openapi_validated.parameters.query.get("limit", 20) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + # Maximum number of items to return without history permission + max_items_no_permission = 20 + + try: + query = request.query(User).include_deleted().filter(User.username == username) + user = query.one_or_none() + if not user: + raise ValueError(f"User with name {username} not found") + except ValueError as exc: + return build_error_response(str(exc), field="username") + + if not request.has_permission("view_history", user) and ( + limit > max_items_no_permission or before or after + ): + return build_error_response( + f"You do not have permission to view this user's history after " + f"the first {max_items_no_permission} items. " + f"Please resubmit your request without pagination parameters. " + f"If you submit a limit, it must be less " + f"than or equal to {max_items_no_permission}.", + status=403, + field="limit/before/after", + error_type="AuthorizationError", + ) + + result_sets = [] + # For the main user API endpoint, combine topics and comments + for type_to_query in [Topic, Comment]: + query = request.query(type_to_query).filter(type_to_query.user == user) + try: + query = query_apply_pagination( + query, before, after, error_if_no_anchor=True + ) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + # include removed posts if the viewer has permission + if request.has_permission("view_removed_posts", user): + query = query.include_removed() + query = query.join_all_relationships() + + result_sets.append(query.get_page(limit)) + + combined_results = MixedPaginatedResults(result_sets) + + # Build the JSON history data + processed_results = [] + for item in combined_results.results: + if isinstance(item, Topic): + processed_results.append(topic_to_dict(item)) + elif isinstance(item, Comment): + processed_results.append(comment_to_dict(request, item)) + + # Construct the paging next and previous link if there are more topics + (next_link, prev_link) = get_next_and_prev_link(request, combined_results) + + # Construct the final response JSON object + response = { + "user": _user_to_dict(user), + "history": processed_results, + "pagination": { + "num_items": len(processed_results), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response + + +@view_config(route_name="apibeta.user_comments", openapi=True, renderer="json") +def get_user_comments(request: Request) -> dict: + """Get comments made by a user.""" + username = request.openapi_validated.parameters.path.get("username") + limit = request.openapi_validated.parameters.query.get("limit", 50) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + try: + query = request.query(User).include_deleted().filter(User.username == username) + user = query.one_or_none() + if not user: + raise ValueError(f"User with name {username} not found") + except ValueError as exc: + return build_error_response(str(exc), field="username") + + if not request.has_permission("view_history", user): + return build_error_response( + "You do not have permission to view this user's comments.", + status=403, + field="N/A", + error_type="AuthorizationError", + ) + + query = request.query(Comment).filter(Comment.user == user) + try: + query = query_apply_pagination(query, before, after, error_if_no_anchor=False) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + # include removed posts if the viewer has permission + if request.has_permission("view_removed_posts", user): + query = query.include_removed() + query = query.join_all_relationships() + + query_result = query.get_page(limit) + + # Build the JSON history data + processed_comments = [] + for comment in query_result.results: + processed_comments.append(comment_to_dict(request, comment)) + + # Construct the paging next and previous link if there are more comments + (next_link, prev_link) = get_next_and_prev_link(request, query_result) + + # Construct the final response JSON object + response = { + "comments": processed_comments, + "pagination": { + "num_items": len(processed_comments), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response + + +@view_config(route_name="apibeta.user_topics", openapi=True, renderer="json") +def get_user_topics(request: Request) -> dict: + """Get topics made by a user.""" + username = request.openapi_validated.parameters.path.get("username") + limit = request.openapi_validated.parameters.query.get("limit", 50) + before = request.openapi_validated.parameters.query.get("before", None) + after = request.openapi_validated.parameters.query.get("after", None) + + try: + query = request.query(User).include_deleted().filter(User.username == username) + user = query.one_or_none() + if not user: + raise ValueError(f"User with name {username} not found") + except ValueError as exc: + return build_error_response(str(exc), field="username") + + if not request.has_permission("view_history", user): + return build_error_response( + "You do not have permission to view this user's topics.", + status=403, + field="N/A", + error_type="AuthorizationError", + ) + + query = request.query(Topic).filter(Topic.user == user) + try: + query = query_apply_pagination(query, before, after, error_if_no_anchor=False) + except ValueError as exc: + return build_error_response(str(exc), field="pagination") + # include removed posts if the viewer has permission + if request.has_permission("view_removed_posts", user): + query = query.include_removed() + query = query.join_all_relationships() + + query_result = query.get_page(limit) + + # Build the JSON history data + processed_topics = [] + for topic in query_result.results: + processed_topics.append(topic_to_dict(topic)) + + # Construct the paging next and previous link if there are more topics + (next_link, prev_link) = get_next_and_prev_link(request, query_result) + + # Construct the final response JSON object + response = { + "topics": processed_topics, + "pagination": { + "num_items": len(processed_topics), + "next_link": next_link, + "prev_link": prev_link, + }, + } + return response