mirror of https://gitlab.com/tildes/tildes.git
Browse Source
Merge branch 'develop-1.101' into 'develop-1.101'
Merge branch 'develop-1.101' into 'develop-1.101'
Tildes API | Read-Only Endpoints See merge request tildes/tildes!160merge-requests/160/merge
17 changed files with 1065 additions and 57 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
@ -0,0 +1,439 @@ |
|||
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 |
@ -0,0 +1 @@ |
|||
"""Contains views for the JSON web API.""" |
@ -0,0 +1,91 @@ |
|||
# 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, |
|||
} |
|||
], |
|||
) |
@ -0,0 +1,79 @@ |
|||
# 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 |
@ -0,0 +1,171 @@ |
|||
# 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 |
@ -0,0 +1,217 @@ |
|||
# 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 |
@ -1 +0,0 @@ |
|||
"""Contains views for v0 of the JSON API.""" |
@ -1,18 +0,0 @@ |
|||
# 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 |
@ -1,20 +0,0 @@ |
|||
# 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 |
@ -1,18 +0,0 @@ |
|||
# 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