mirror of https://gitlab.com/tildes/tildes.git
Browse Source
Revert "Tildes API | Read-Only Endpoints"
Revert "Tildes API | Read-Only Endpoints"
This reverts commit 7d285e94e8.
Defer JSON API to v1.102
merge-requests/171/merge
17 changed files with 57 additions and 1065 deletions
-
439tildes/openapi_beta.yaml
-
17tildes/requirements-dev.txt
-
1tildes/requirements.in
-
17tildes/requirements.txt
-
1tildes/tildes/__init__.py
-
18tildes/tildes/lib/id.py
-
1tildes/tildes/models/topic/topic_query.py
-
12tildes/tildes/routes.py
-
1tildes/tildes/views/api/beta/__init__.py
-
91tildes/tildes/views/api/beta/api_utils.py
-
79tildes/tildes/views/api/beta/comment.py
-
171tildes/tildes/views/api/beta/topic.py
-
217tildes/tildes/views/api/beta/user.py
-
1tildes/tildes/views/api/v0/__init__.py
-
18tildes/tildes/views/api/v0/group.py
-
20tildes/tildes/views/api/v0/topic.py
-
18tildes/tildes/views/api/v0/user.py
@ -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 |
|
||||
@ -1 +0,0 @@ |
|||||
"""Contains views for the JSON web API.""" |
|
||||
@ -1,91 +0,0 @@ |
|||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
|
||||
# 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, |
|
||||
} |
|
||||
], |
|
||||
) |
|
||||
@ -1,79 +0,0 @@ |
|||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
|
||||
# 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 |
|
||||
@ -1,171 +0,0 @@ |
|||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
|
||||
# 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 |
|
||||
@ -1,217 +0,0 @@ |
|||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
|
||||
# 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 |
|
||||
@ -0,0 +1 @@ |
|||||
|
"""Contains views for v0 of the JSON API.""" |
||||
@ -0,0 +1,18 @@ |
|||||
|
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
||||
|
# 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 |
||||
@ -0,0 +1,20 @@ |
|||||
|
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
||||
|
# 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 |
||||
@ -0,0 +1,18 @@ |
|||||
|
# Copyright (c) 2018 Tildes contributors <code@tildes.net> |
||||
|
# 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 |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue