Browse Source

Merge branch 'develop-1.101' into 'develop-1.101'

Tildes API | Read-Only Endpoints

See merge request tildes/tildes!160
merge-requests/160/merge
Polle 2 months ago
parent
commit
c688744458
  1. 439
      tildes/openapi_beta.yaml
  2. 17
      tildes/requirements-dev.txt
  3. 1
      tildes/requirements.in
  4. 17
      tildes/requirements.txt
  5. 1
      tildes/tildes/__init__.py
  6. 18
      tildes/tildes/lib/id.py
  7. 1
      tildes/tildes/models/topic/topic_query.py
  8. 12
      tildes/tildes/routes.py
  9. 1
      tildes/tildes/views/api/beta/__init__.py
  10. 91
      tildes/tildes/views/api/beta/api_utils.py
  11. 79
      tildes/tildes/views/api/beta/comment.py
  12. 171
      tildes/tildes/views/api/beta/topic.py
  13. 217
      tildes/tildes/views/api/beta/user.py
  14. 1
      tildes/tildes/views/api/v0/__init__.py
  15. 18
      tildes/tildes/views/api/v0/group.py
  16. 20
      tildes/tildes/views/api/v0/topic.py
  17. 18
      tildes/tildes/views/api/v0/user.py

439
tildes/openapi_beta.yaml

@ -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

17
tildes/requirements-dev.txt

@ -4,6 +4,7 @@ argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
astroid==3.3.11
asttokens==3.0.0
attrs==25.3.0
beautifulsoup4==4.13.4
black==25.1.0
bleach==6.2.0
@ -31,20 +32,31 @@ iniconfig==2.1.0
invoke==2.2.0
ipython==9.4.0
ipython-pygments-lexers==1.1.1
isodate==0.7.2
isort==6.0.1
jedi==0.19.2
jinja2==3.1.6
jsonschema==4.25.0
jsonschema-path==0.3.4
jsonschema-specifications==2025.4.1
lazy-object-proxy==1.11.0
lupa==2.5
mako==1.3.10
markupsafe==3.0.2
marshmallow==3.25.1
matplotlib-inline==0.1.7
mccabe==0.7.0
more-itertools==10.7.0
mypy==1.17.1
mypy-extensions==1.1.0
openapi-core==0.19.5
openapi-schema-validator==0.6.3
openapi-spec-validator==0.7.2
packaging==25.0
parse==1.20.2
parso==0.8.4
pastedeploy==3.1.0
pathable==0.4.4
pathspec==0.12.1
pep8-naming==0.10.0
pexpect==4.9.0
@ -78,6 +90,7 @@ pyramid-debugtoolbar==4.12.1
pyramid-ipython==0.2
pyramid-jinja2==2.10.1
pyramid-mako==1.1.0
pyramid-openapi3==0.21.0
pyramid-session-redis==1.5.0
pyramid-tm==2.6
pyramid-webassets==0.10
@ -87,8 +100,11 @@ python-dateutil==2.9.0.post0
pyyaml==6.0.2
qrcode==8.2
redis==3.5.3
referencing==0.36.2
requests==2.32.4
requirements-detector==1.4.0
rfc3339-validator==0.1.4
rpds-py==0.27.0
semver==3.0.4
sentry-sdk==1.3.0
setoptconf-tmp==0.3.1
@ -124,6 +140,7 @@ webassets==2.0
webencodings==0.5.1
webob==1.8.9
webtest==3.0.6
werkzeug==3.1.1
wheel==0.45.1
wrapt==1.17.2
zope-deprecation==5.1

1
tildes/requirements.in

@ -22,6 +22,7 @@ pyotp
pyramid<2.0
pyramid-ipython
pyramid-jinja2
pyramid-openapi3>=0.17.0
pyramid-session-redis==1.5.0 # 1.5.1 has a change that will invalidate current sessions
pyramid-tm
pyramid-webassets

17
tildes/requirements.txt

@ -3,6 +3,7 @@ alembic==1.14.1
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
asttokens==3.0.0
attrs==25.3.0
beautifulsoup4==4.13.4
bleach==6.2.0
build==1.3.0
@ -20,16 +21,27 @@ idna==3.10
invoke==2.2.0
ipython==9.4.0
ipython-pygments-lexers==1.1.1
isodate==0.7.2
jedi==0.19.2
jinja2==3.1.6
jsonschema==4.25.0
jsonschema-path==0.3.4
jsonschema-specifications==2025.4.1
lazy-object-proxy==1.11.0
lupa==2.5
mako==1.3.10
markupsafe==3.0.2
marshmallow==3.25.1
matplotlib-inline==0.1.7
more-itertools==10.7.0
openapi-core==0.19.5
openapi-schema-validator==0.6.3
openapi-spec-validator==0.7.2
packaging==25.0
parse==1.20.2
parso==0.8.4
pastedeploy==3.1.0
pathable==0.4.4
pexpect==4.9.0
pillow==11.3.0
pip-tools==7.5.0
@ -49,6 +61,7 @@ pyproject-hooks==1.2.0
pyramid==1.10.8
pyramid-ipython==0.2
pyramid-jinja2==2.10.1
pyramid-openapi3==0.21.0
pyramid-session-redis==1.5.0
pyramid-tm==2.6
pyramid-webassets==0.10
@ -56,7 +69,10 @@ python-dateutil==2.9.0.post0
pyyaml==6.0.2
qrcode==8.2
redis==3.5.3
referencing==0.36.2
requests==2.32.4
rfc3339-validator==0.1.4
rpds-py==0.27.0
sentry-sdk==1.3.0
six==1.17.0
soupsieve==2.7
@ -77,6 +93,7 @@ webargs==8.0.0
webassets==2.0
webencodings==0.5.1
webob==1.8.9
werkzeug==3.1.1
wheel==0.45.1
wrapt==1.17.2
zope-deprecation==5.1

1
tildes/tildes/__init__.py

@ -18,6 +18,7 @@ def main(global_config: dict[str, str], **settings: str) -> PrefixMiddleware:
config.include("cornice")
config.include("pyramid_session_redis")
config.include("pyramid_webassets")
config.include("pyramid_openapi3")
# include database first so the session and querying are available
config.include("tildes.database")

18
tildes/tildes/lib/id.py

@ -5,6 +5,7 @@
import re
import string
from typing import Literal, Tuple
ID36_REGEX = re.compile("^[a-z0-9]+$", re.IGNORECASE)
@ -41,3 +42,20 @@ def id36_to_id(id36_val: str) -> int:
# Python's stdlib can handle this, much simpler in this direction
return int(id36_val, 36)
def split_anchored_id(anchored_id: str) -> Tuple[Literal["comment", "topic"], str]:
"""Extract the anchor part from an anchored ID."""
if not anchored_id or not isinstance(anchored_id, str):
raise ValueError("Invalid anchored ID provided")
type_char, _, id36 = anchored_id.partition("-")
if not id36:
raise ValueError("Invalid anchored ID provided")
if type_char == "c":
return ("comment", id36)
elif type_char == "t":
return ("topic", id36)
else:
raise ValueError(f"Invalid anchored ID type: {type_char}")

1
tildes/tildes/models/topic/topic_query.py

@ -142,6 +142,7 @@ class TopicQuery(PaginatedQuery):
topic.bookmark_created_time = None
topic.last_visit_time = None
topic.comments_since_last_visit = None
topic.user_bookmarked = None
topic.user_ignored = False
else:
topic = result.Topic

12
tildes/tildes/routes.py

@ -128,6 +128,18 @@ def includeme(config: Configurator) -> None:
config.add_route("shortener_group", "/~{path}", factory=group_by_path)
config.add_route("shortener_topic", "/{topic_id36}", factory=topic_by_id36)
# Routes for the JSON API
# We also provide a path for the full spec and the built-in swagger UI explorer
config.pyramid_openapi3_spec("openapi_beta.yaml", route="/api/beta/openapi.yaml")
config.pyramid_openapi3_add_explorer(route="/api/beta/ui")
with config.route_prefix_context("/api/beta"):
config.add_route("apibeta.topics", "/topics")
config.add_route("apibeta.topic", "/topic/{topic_id36}")
config.add_route("apibeta.user", "/user/{username}")
config.add_route("apibeta.user_comments", "/user/{username}/comments")
config.add_route("apibeta.user_topics", "/user/{username}/topics")
def add_intercooler_routes(config: Configurator) -> None:
"""Set up all routes for the (internal-use) Intercooler API endpoints."""

1
tildes/tildes/views/api/beta/__init__.py

@ -0,0 +1 @@
"""Contains views for the JSON web API."""

91
tildes/tildes/views/api/beta/api_utils.py

@ -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,
}
],
)

79
tildes/tildes/views/api/beta/comment.py

@ -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

171
tildes/tildes/views/api/beta/topic.py

@ -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

217
tildes/tildes/views/api/beta/user.py

@ -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
tildes/tildes/views/api/v0/__init__.py

@ -1 +0,0 @@
"""Contains views for v0 of the JSON API."""

18
tildes/tildes/views/api/v0/group.py

@ -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

20
tildes/tildes/views/api/v0/topic.py

@ -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

18
tildes/tildes/views/api/v0/user.py

@ -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
Loading…
Cancel
Save