diff --git a/tildes/tildes/models/topic/topic_query.py b/tildes/tildes/models/topic/topic_query.py index 8eb63a8..88694f9 100644 --- a/tildes/tildes/models/topic/topic_query.py +++ b/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 diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index 9593fa1..d27b3ef 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -130,8 +130,10 @@ def includeme(config: Configurator) -> None: # 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') + config.pyramid_openapi3_spec("openapi_beta.yaml", route="/api/beta/openapi.yaml") + config.pyramid_openapi3_add_explorer(route="/api/beta/ui") + + config.add_route("apibeta.topics", "/api/beta/topics") def add_intercooler_routes(config: Configurator) -> None: diff --git a/tildes/tildes/views/api/beta/__init__.py b/tildes/tildes/views/api/beta/__init__.py new file mode 100644 index 0000000..9e7a664 --- /dev/null +++ b/tildes/tildes/views/api/beta/__init__.py @@ -0,0 +1 @@ +"""Contains views for the JSON web API""" diff --git a/tildes/tildes/views/api/beta/topic.py b/tildes/tildes/views/api/beta/topic.py new file mode 100644 index 0000000..ec7d6b4 --- /dev/null +++ b/tildes/tildes/views/api/beta/topic.py @@ -0,0 +1,141 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API endpoints related to topics.""" + +from marshmallow.exceptions import ValidationError +from pyramid.exceptions import HTTPBadRequest +from pyramid.request import Request +from pyramid.response import Response +from pyramid.view import view_config +from tildes.enums import TopicSortOption +from tildes.models.topic import Topic +from tildes.schemas.fields import ShortTimePeriod + + +@view_config(route_name="apibeta.topics", openapi=True, renderer="json") +def get_topics(request: Request) -> dict: + """Get topics""" + limit = request.openapi_validated.parameters.query.get("limit") + 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 Response( + status=400, + content_type="application/json", + json=[ + {"message": str(exc), "field": "period", "exception": "ValidationError"} + ], + ) + + try: + if order_raw: + order = TopicSortOption[order_raw.upper()] + else: + order = TopicSortOption.ACTIVITY + except KeyError: + return Response( + status=400, + content_type="application/json", + json=[ + { + "message": f"Invalid order value: {order_raw}", + "field": "order", + "exception": "ValidationError", + } + ], + ) + + 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: + if before and after: + raise ValueError("Cannot specify both before and after parameters") + if before: + query = query.before_id36(before) + if after: + query = query.after_id36(after) + except ValueError as exc: + return Response( + status=400, + content_type="application/json", + json=[ + { + "message": str(exc), + "field": "before/after", + "exception": "ValidationError", + } + ], + ) + + # Execute the query + topics = query.get_page(limit) + processed_topics = [] + + # Build the JSON topic data + for topic in topics: + processed_topic = { + "id": topic.topic_id, + "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(), + "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, + } + processed_topics.append(processed_topic) + + # Construct the paging next and previous link if there are more topics + if topics.has_next_page: + query_vars = request.GET.copy() + query_vars.pop("before", None) + query_vars.update({"after": topics.next_page_after_id36}) + next_link = request.current_route_url(_query=query_vars) + else: + next_link = None + + if topics.has_prev_page: + query_vars = request.GET.copy() + query_vars.pop("after", None) + query_vars.update({"before": topics.prev_page_before_id36}) + prev_link = request.current_route_url(_query=query_vars) + else: + prev_link = None + + # 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