diff --git a/tildes/openapi_beta.yaml b/tildes/openapi_beta.yaml deleted file mode 100644 index 8f4de6e..0000000 --- a/tildes/openapi_beta.yaml +++ /dev/null @@ -1,439 +0,0 @@ -openapi: 3.0.0 -info: - title: Tildes Beta API Schema - version: Beta - description: | - This is the OpenAPI schema for the Tildes Beta API. - The beta API is subject to change and may not be fully stable. - Future updates WILL include breaking changes. - Use at your own risk. -servers: - - url: /api/beta -paths: - /topics: - get: - summary: Get a list of topics - parameters: - - $ref: '#/components/parameters/paginationLimit' - - $ref: '#/components/parameters/paginationBefore' - - $ref: '#/components/parameters/paginationAfter' - - in: query - name: period - schema: - type: string - default: "all" - required: false - description: The time period for which to retrieve topics. For example "4h" or "2d". - - in: query - name: tag - schema: - type: string - required: false - description: The tag to filter topics by. If not specified, topics are not filtered on their tags. - - in: query - name: order - schema: - type: string - default: "activity" - enum: ["activity", "votes", "comments", "new", "all_activity"] - required: false - description: The sort order for the topics. Defaults to "activity". - responses: - "200": - description: A list of topics - 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" - - /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" - - /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: - paginationBefore: - in: query - name: before - schema: - type: string - required: false - 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. In mixed feeds like user profiles, this parameter needs a "t-" or "c-" prefix to indicate topics and comments respectively. - - paginationAfter: - in: query - name: after - schema: - type: string - required: false - description: The ID36 of the last item from the previous page, to get items after it. You can only specify either `before` or `after`, not both. In mixed feeds like user profiles, this parameter needs a "t-" or "c-" prefix to indicate topics and comments respectively. - - paginationLimit: - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 100 - required: false - description: The maximum number of items to return. The `limit` is itself limited to prevent abuse. - - responses: - ValidationError: - description: OpenAPI request/response validation failed - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Error" - AuthorizationError: - description: You are not authorized to perform this action - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Error" - - schemas: - Topic: - type: object - required: - - id - - title - - text_html - - url - - comments_url - - group - - content_metadata - - created_at - - edited_at - - posted_by_user - - vote_count - - comment_count - - new_comment_count - - voted - - bookmarked - - ignored - - official - - tags - - last_visit_time - properties: - id: - type: string - title: - type: string - text_html: - type: string - nullable: true - url: - type: string - nullable: true - comments_url: - type: string - source_site_name: - type: string - nullable: true - source_site_icon: - type: string - nullable: true - group: - type: string - content_metadata: - type: array - items: - type: string - created_at: - type: string - edited_at: - type: string - nullable: true - posted_by_user: - type: string - vote_count: - type: integer - comment_count: - type: integer - new_comment_count: - type: integer - nullable: true - voted: - type: boolean - nullable: true - bookmarked: - type: boolean - nullable: true - ignored: - type: boolean - nullable: true - official: - type: boolean - tags: - type: array - items: - type: string - last_visit_time: - type: string - nullable: true - - Comment: - type: object - required: - - id - - topic_id - - author - - rendered_html - - created_at - - edited_at - - votes - - is_removed - - is_deleted - - exemplary - - voted - - bookmarked - properties: - id: - type: string - topic_id: - type: string - author: - type: string - nullable: true - rendered_html: - type: string - nullable: true - created_at: - type: string - edited_at: - type: string - nullable: true - votes: - type: integer - is_removed: - type: boolean - is_deleted: - type: boolean - exemplary: - type: boolean - nullable: true - collapsed: - type: boolean - nullable: true - collapsed_individual: - type: boolean - nullable: true - is_op: - type: boolean - nullable: true - is_me: - type: boolean - nullable: true - is_new: - type: boolean - nullable: true - voted: - type: boolean - nullable: true - bookmarked: - type: boolean - nullable: true - depth: - type: integer - children: - type: array - items: - $ref: '#/components/schemas/Comment' - - User: - type: object - required: - - username - properties: - username: - type: string - - Pagination: - type: object - required: - - num_items - - next_link - - prev_link - properties: - num_items: - type: integer - description: The number of items returned in this response. - next_link: - type: string - nullable: true - prev_link: - type: string - nullable: true - - Error: - type: object - required: - - message - properties: - field: - type: string - message: - type: string - exception: - type: string diff --git a/tildes/requirements-dev.txt b/tildes/requirements-dev.txt index 2c0ef37..1634bef 100644 --- a/tildes/requirements-dev.txt +++ b/tildes/requirements-dev.txt @@ -4,7 +4,6 @@ argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 astroid==3.3.11 asttokens==3.0.0 -attrs==25.3.0 beautifulsoup4==4.13.4 black==25.1.0 bleach==6.2.0 @@ -32,31 +31,20 @@ iniconfig==2.1.0 invoke==2.2.0 ipython==9.4.0 ipython-pygments-lexers==1.1.1 -isodate==0.7.2 isort==6.0.1 jedi==0.19.2 jinja2==3.1.6 -jsonschema==4.25.0 -jsonschema-path==0.3.4 -jsonschema-specifications==2025.4.1 -lazy-object-proxy==1.11.0 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 marshmallow==3.25.1 matplotlib-inline==0.1.7 mccabe==0.7.0 -more-itertools==10.7.0 mypy==1.17.1 mypy-extensions==1.1.0 -openapi-core==0.19.5 -openapi-schema-validator==0.6.3 -openapi-spec-validator==0.7.2 packaging==25.0 -parse==1.20.2 parso==0.8.4 pastedeploy==3.1.0 -pathable==0.4.4 pathspec==0.12.1 pep8-naming==0.10.0 pexpect==4.9.0 @@ -90,7 +78,6 @@ pyramid-debugtoolbar==4.12.1 pyramid-ipython==0.2 pyramid-jinja2==2.10.1 pyramid-mako==1.1.0 -pyramid-openapi3==0.21.0 pyramid-session-redis==1.5.0 pyramid-tm==2.6 pyramid-webassets==0.10 @@ -100,11 +87,8 @@ python-dateutil==2.9.0.post0 pyyaml==6.0.2 qrcode==8.2 redis==3.5.3 -referencing==0.36.2 requests==2.32.4 requirements-detector==1.4.0 -rfc3339-validator==0.1.4 -rpds-py==0.27.0 semver==3.0.4 sentry-sdk==1.3.0 setoptconf-tmp==0.3.1 @@ -140,7 +124,6 @@ webassets==2.0 webencodings==0.5.1 webob==1.8.9 webtest==3.0.6 -werkzeug==3.1.1 wheel==0.45.1 wrapt==1.17.2 zope-deprecation==5.1 diff --git a/tildes/requirements.in b/tildes/requirements.in index 15af711..a7ee0cf 100644 --- a/tildes/requirements.in +++ b/tildes/requirements.in @@ -22,7 +22,6 @@ pyotp pyramid<2.0 pyramid-ipython pyramid-jinja2 -pyramid-openapi3>=0.17.0 pyramid-session-redis==1.5.0 # 1.5.1 has a change that will invalidate current sessions pyramid-tm pyramid-webassets diff --git a/tildes/requirements.txt b/tildes/requirements.txt index 4e73db4..477544c 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -3,7 +3,6 @@ alembic==1.14.1 argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 asttokens==3.0.0 -attrs==25.3.0 beautifulsoup4==4.13.4 bleach==6.2.0 build==1.3.0 @@ -21,27 +20,16 @@ idna==3.10 invoke==2.2.0 ipython==9.4.0 ipython-pygments-lexers==1.1.1 -isodate==0.7.2 jedi==0.19.2 jinja2==3.1.6 -jsonschema==4.25.0 -jsonschema-path==0.3.4 -jsonschema-specifications==2025.4.1 -lazy-object-proxy==1.11.0 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 marshmallow==3.25.1 matplotlib-inline==0.1.7 -more-itertools==10.7.0 -openapi-core==0.19.5 -openapi-schema-validator==0.6.3 -openapi-spec-validator==0.7.2 packaging==25.0 -parse==1.20.2 parso==0.8.4 pastedeploy==3.1.0 -pathable==0.4.4 pexpect==4.9.0 pillow==11.3.0 pip-tools==7.5.0 @@ -61,7 +49,6 @@ pyproject-hooks==1.2.0 pyramid==1.10.8 pyramid-ipython==0.2 pyramid-jinja2==2.10.1 -pyramid-openapi3==0.21.0 pyramid-session-redis==1.5.0 pyramid-tm==2.6 pyramid-webassets==0.10 @@ -69,10 +56,7 @@ python-dateutil==2.9.0.post0 pyyaml==6.0.2 qrcode==8.2 redis==3.5.3 -referencing==0.36.2 requests==2.32.4 -rfc3339-validator==0.1.4 -rpds-py==0.27.0 sentry-sdk==1.3.0 six==1.17.0 soupsieve==2.7 @@ -93,7 +77,6 @@ webargs==8.0.0 webassets==2.0 webencodings==0.5.1 webob==1.8.9 -werkzeug==3.1.1 wheel==0.45.1 wrapt==1.17.2 zope-deprecation==5.1 diff --git a/tildes/tildes/__init__.py b/tildes/tildes/__init__.py index 7f9a6e7..58e626f 100644 --- a/tildes/tildes/__init__.py +++ b/tildes/tildes/__init__.py @@ -18,7 +18,6 @@ def main(global_config: dict[str, str], **settings: str) -> PrefixMiddleware: config.include("cornice") config.include("pyramid_session_redis") config.include("pyramid_webassets") - config.include("pyramid_openapi3") # include database first so the session and querying are available config.include("tildes.database") diff --git a/tildes/tildes/lib/id.py b/tildes/tildes/lib/id.py index 88584d8..6b331e6 100644 --- a/tildes/tildes/lib/id.py +++ b/tildes/tildes/lib/id.py @@ -5,7 +5,6 @@ import re import string -from typing import Literal, Tuple ID36_REGEX = re.compile("^[a-z0-9]+$", re.IGNORECASE) @@ -42,20 +41,3 @@ def id36_to_id(id36_val: str) -> int: # Python's stdlib can handle this, much simpler in this direction return int(id36_val, 36) - - -def split_anchored_id(anchored_id: str) -> Tuple[Literal["comment", "topic"], str]: - """Extract the anchor part from an anchored ID.""" - if not anchored_id or not isinstance(anchored_id, str): - raise ValueError("Invalid anchored ID provided") - - type_char, _, id36 = anchored_id.partition("-") - if not id36: - raise ValueError("Invalid anchored ID provided") - - if type_char == "c": - return ("comment", id36) - elif type_char == "t": - return ("topic", id36) - else: - raise ValueError(f"Invalid anchored ID type: {type_char}") diff --git a/tildes/tildes/models/topic/topic_query.py b/tildes/tildes/models/topic/topic_query.py index 88694f9..8eb63a8 100644 --- a/tildes/tildes/models/topic/topic_query.py +++ b/tildes/tildes/models/topic/topic_query.py @@ -142,7 +142,6 @@ class TopicQuery(PaginatedQuery): topic.bookmark_created_time = None topic.last_visit_time = None topic.comments_since_last_visit = None - topic.user_bookmarked = None topic.user_ignored = False else: topic = result.Topic diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index ea77bba..bd8b8f4 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -128,18 +128,6 @@ def includeme(config: Configurator) -> None: config.add_route("shortener_group", "/~{path}", factory=group_by_path) config.add_route("shortener_topic", "/{topic_id36}", factory=topic_by_id36) - # Routes for the JSON API - # We also provide a path for the full spec and the built-in swagger UI explorer - config.pyramid_openapi3_spec("openapi_beta.yaml", route="/api/beta/openapi.yaml") - config.pyramid_openapi3_add_explorer(route="/api/beta/ui") - - 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: """Set up all routes for the (internal-use) Intercooler API endpoints.""" diff --git a/tildes/tildes/views/api/beta/__init__.py b/tildes/tildes/views/api/beta/__init__.py deleted file mode 100644 index a518b4a..0000000 --- a/tildes/tildes/views/api/beta/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Contains views for the JSON web API.""" diff --git a/tildes/tildes/views/api/beta/api_utils.py b/tildes/tildes/views/api/beta/api_utils.py deleted file mode 100644 index e997f07..0000000 --- a/tildes/tildes/views/api/beta/api_utils.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""JSON API utils.""" - -from typing import Tuple -from pyramid.request import Request -from pyramid.response import Response -from tildes.lib.id import split_anchored_id -from tildes.models.pagination import PaginatedQuery, PaginatedResults - - -def query_apply_pagination( # noqa - query: PaginatedQuery, - before: str | None, - after: str | None, - error_if_no_anchor: bool = False, -) -> PaginatedQuery: - """Apply pagination parameters to a query.""" - # Start by parsing the before/after parameters and extracting the anchor type - # We don't know if the ID has an anchor, so we just try to split it - # If it doesn't have an anchor, we just use the ID as is. - anchor_type = None - if before and after: - raise ValueError("Cannot specify both before and after parameters") - if before: - try: - anchor_type, before = split_anchored_id(before) - except ValueError as exc: - if error_if_no_anchor: - raise ValueError( - "Expected an anchored ID for 'before' parameter" - ) from exc - if after: - try: - anchor_type, after = split_anchored_id(after) - except ValueError as exc: - if error_if_no_anchor: - raise ValueError( - "Expected an anchored ID for 'after' parameter" - ) from exc - - if anchor_type: - query = query.anchor_type(anchor_type) - if before: - query = query.before_id36(before) - if after: - query = query.after_id36(after) - return query - - -def get_next_and_prev_link( - request: Request, page: PaginatedResults -) -> Tuple[str | None, str | None]: - """Get the next and previous links for pagination.""" - next_link = None - prev_link = None - - if page.has_next_page: - query_vars = request.GET.copy() - query_vars.pop("before", None) - query_vars.update({"after": page.next_page_after_id36}) - next_link = request.current_route_url(_query=query_vars) - - if page.has_prev_page: - query_vars = request.GET.copy() - query_vars.pop("after", None) - query_vars.update({"before": page.prev_page_before_id36}) - prev_link = request.current_route_url(_query=query_vars) - - return (next_link, prev_link) - - -def build_error_response( - message: str, - status: int = 400, - field: str = "N/A", - error_type: str = "ValidationError", -) -> Response: - """Build a standardized error response.""" - return Response( - status=status, - content_type="application/json", - json=[ - { - "message": message, - "field": field, - "exception": error_type, - } - ], - ) diff --git a/tildes/tildes/views/api/beta/comment.py b/tildes/tildes/views/api/beta/comment.py deleted file mode 100644 index ee8da1b..0000000 --- a/tildes/tildes/views/api/beta/comment.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""JSON API helper functions related to comments.""" - -from pyramid.request import Request -from tildes.models.comment import Comment -from tildes.models.comment.comment_tree import CommentInTree - - -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 ( - hasattr(comment.topic, "last_visit_time") - and 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") - if hasattr(comment, "collapsed_state") - else None - ), - "collapsed_individual": ( - (comment.collapsed_state == "individual") - if hasattr(comment, "collapsed_state") - else None - ), - "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 diff --git a/tildes/tildes/views/api/beta/topic.py b/tildes/tildes/views/api/beta/topic.py deleted file mode 100644 index b8c87f3..0000000 --- a/tildes/tildes/views/api/beta/topic.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""JSON API endpoints related to topics.""" - -from marshmallow.exceptions import ValidationError -from pyramid.request import Request -from pyramid.view import view_config -from tildes.enums import CommentTreeSortOption, TopicSortOption -from tildes.models.comment import CommentTree, Comment -from tildes.models.topic import Topic -from tildes.schemas.fields import ShortTimePeriod -from tildes.lib.id import id36_to_id -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_subtree_to_dict - - -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(), - "edited_at": ( - topic.last_edited_time.isoformat() if topic.last_edited_time else None - ), - "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 - ), - } - - -@view_config(route_name="apibeta.topics", openapi=True, renderer="json") -def get_topics(request: Request) -> dict: # noqa - """Get a list of topics (without comments).""" - limit = request.openapi_validated.parameters.query.get("limit", 50) - period_raw = request.openapi_validated.parameters.query.get("period") - tag = request.openapi_validated.parameters.query.get("tag", None) - order_raw = request.openapi_validated.parameters.query.get("order", None) - before = request.openapi_validated.parameters.query.get("before", None) - after = request.openapi_validated.parameters.query.get("after", None) - - # Parse parameters where necessary - try: - period = ShortTimePeriod(allow_none=True) - period = period.deserialize(period_raw) - except ValidationError as exc: - return build_error_response(str(exc), field="period") - - try: - if order_raw: - order = TopicSortOption[order_raw.upper()] - else: - order = TopicSortOption.ACTIVITY - except KeyError: - return build_error_response(f"Invalid order value: {order_raw}", field="order") - - query = request.query(Topic).join_all_relationships().apply_sort_option(order) - - # restrict the time period, if not set to "all time" - if period: - query = query.inside_time_period(period) - - # restrict to a specific tag if provided - if tag: - query = query.has_tag(tag) - - # apply before/after pagination restrictions if relevant - 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") - - # Execute the query - topics = query.get_page(limit) - processed_topics = [] - - # Build the JSON topic data - for topic in topics: - processed_topic = topic_to_dict(topic) - processed_topics.append(processed_topic) - - # Construct the paging next and previous link if there are more topics - (next_link, prev_link) = get_next_and_prev_link(request, topics) - - # 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 - - -@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 build_error_response( - f"Invalid order value: {comment_order_raw}", - field="order", - ) - 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 build_error_response(str(exc), field="topic_id36") - - # 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 diff --git a/tildes/tildes/views/api/beta/user.py b/tildes/tildes/views/api/beta/user.py deleted file mode 100644 index 3b5b865..0000000 --- a/tildes/tildes/views/api/beta/user.py +++ /dev/null @@ -1,217 +0,0 @@ -# Copyright (c) 2018 Tildes contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""JSON API endpoints related to users.""" - -from typing import Union -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 - types_to_query: list[Union[type[Topic], type[Comment]]] = [Topic, Comment] - for type_to_query in types_to_query: - 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 diff --git a/tildes/tildes/views/api/v0/__init__.py b/tildes/tildes/views/api/v0/__init__.py new file mode 100644 index 0000000..54b9f00 --- /dev/null +++ b/tildes/tildes/views/api/v0/__init__.py @@ -0,0 +1 @@ +"""Contains views for v0 of the JSON API.""" diff --git a/tildes/tildes/views/api/v0/group.py b/tildes/tildes/views/api/v0/group.py new file mode 100644 index 0000000..0cb1666 --- /dev/null +++ b/tildes/tildes/views/api/v0/group.py @@ -0,0 +1,18 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""API v0 endpoints related to groups.""" + +from pyramid.request import Request + +from tildes.api import APIv0 +from tildes.resources.group import group_by_path + + +ONE = APIv0(name="group", path="/groups/{path}", factory=group_by_path) + + +@ONE.get() +def get_group(request: Request) -> dict: + """Get a single group's data.""" + return request.context diff --git a/tildes/tildes/views/api/v0/topic.py b/tildes/tildes/views/api/v0/topic.py new file mode 100644 index 0000000..5e1536c --- /dev/null +++ b/tildes/tildes/views/api/v0/topic.py @@ -0,0 +1,20 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""API v0 endpoints related to topics.""" + +from pyramid.request import Request + +from tildes.api import APIv0 +from tildes.resources.topic import topic_by_id36 + + +ONE = APIv0( + name="topic", path="/groups/{path}/topics/{topic_id36}", factory=topic_by_id36 +) + + +@ONE.get() +def get_topic(request: Request) -> dict: + """Get a single topic's data.""" + return request.context diff --git a/tildes/tildes/views/api/v0/user.py b/tildes/tildes/views/api/v0/user.py new file mode 100644 index 0000000..9a58706 --- /dev/null +++ b/tildes/tildes/views/api/v0/user.py @@ -0,0 +1,18 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""API v0 endpoints related to users.""" + +from pyramid.request import Request + +from tildes.api import APIv0 +from tildes.resources.user import user_by_username + + +ONE = APIv0(name="user", path="/users/{username}", factory=user_by_username) + + +@ONE.get() +def get_user(request: Request) -> dict: + """Get a single user's data.""" + return request.context