mirror of https://gitlab.com/tildes/tildes.git
Browse Source
Tildes API | Read-Only Endpoints
Tildes API | Read-Only Endpoints
See merge request tildes/tildes!160merge-requests/137/head
committed by
talklittle
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