From fc8a21fc5c6809ea115092eeec57e09e984cdd76 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 11 Dec 2022 11:22:06 -0700 Subject: More API read coverage --- api/schemas.py | 162 +++++++++++++++++++++++++++++++++++++++++++++ api/schemas/__init__.py | 108 ------------------------------ api/views/__init__.py | 4 +- api/views/accounts.py | 91 ++++++++++++++++++++++++- api/views/apps.py | 4 +- api/views/base.py | 2 +- api/views/instance.py | 6 +- api/views/notifications.py | 52 +++++++++++++++ api/views/search.py | 42 ++++++++++++ api/views/timelines.py | 126 ++++++++++++++++++++++++++++++++--- 10 files changed, 470 insertions(+), 127 deletions(-) create mode 100644 api/schemas.py delete mode 100644 api/schemas/__init__.py create mode 100644 api/views/notifications.py create mode 100644 api/views/search.py (limited to 'api') diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 0000000..a8f4e45 --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,162 @@ +from typing import Literal, Optional, Union + +from ninja import Field, Schema + + +class Application(Schema): + id: str + name: str + website: str | None + client_id: str + client_secret: str + redirect_uri: str = Field(alias="redirect_uris") + + +class CustomEmoji(Schema): + shortcode: str + url: str + static_url: str + visible_in_picker: bool + category: str + + +class AccountField(Schema): + name: str + value: str + verified_at: str | None + + +class Account(Schema): + id: str + username: str + acct: str + url: str + display_name: str + note: str + avatar: str + avatar_static: str + header: str + header_static: str + locked: bool + fields: list[AccountField] + emojis: list[CustomEmoji] + bot: bool + group: bool + discoverable: bool + moved: Union[None, bool, "Account"] + suspended: bool + limited: bool + created_at: str + last_status_at: str | None = Field(...) + statuses_count: int + followers_count: int + following_count: int + + +class MediaAttachment(Schema): + id: str + type: Literal["unknown", "image", "gifv", "video", "audio"] + url: str + preview_url: str + remote_url: str | None + meta: dict + description: str | None + blurhash: str | None + + +class StatusMention(Schema): + id: str + username: str + url: str + acct: str + + +class StatusTag(Schema): + name: str + url: str + + +class Status(Schema): + id: str + uri: str + created_at: str + account: Account + content: str + visibility: Literal["public", "unlisted", "private", "direct"] + sensitive: bool + spoiler_text: str + media_attachments: list[MediaAttachment] + mentions: list[StatusMention] + tags: list[StatusTag] + emojis: list[CustomEmoji] + reblogs_count: int + favourites_count: int + replies_count: int + url: str | None = Field(...) + in_reply_to_id: str | None = Field(...) + in_reply_to_account_id: str | None = Field(...) + reblog: Optional["Status"] = Field(...) + poll: None = Field(...) + card: None = Field(...) + language: None = Field(...) + text: str | None = Field(...) + edited_at: str | None + favourited: bool | None + reblogged: bool | None + muted: bool | None + bookmarked: bool | None + pinned: bool | None + + +class Conversation(Schema): + id: str + unread: bool + accounts: list[Account] + last_status: Status | None = Field(...) + + +class Notification(Schema): + id: str + type: Literal[ + "mention", + "status", + "reblog", + "follow", + "follow_request", + "favourite", + "poll", + "update", + "admin.sign_up", + "admin.report", + ] + created_at: str + account: Account + status: Status | None + + +class Tag(Schema): + name: str + url: str + history: dict + + +class Search(Schema): + accounts: list[Account] + statuses: list[Status] + hashtags: list[Tag] + + +class Relationship(Schema): + id: str + following: bool + followed_by: bool + showing_reblogs: bool + notifying: bool + blocking: bool + blocked_by: bool + muting: bool + muting_notifications: bool + requested: bool + domain_blocking: bool + endorsed: bool + note: str diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py deleted file mode 100644 index cc0660c..0000000 --- a/api/schemas/__init__.py +++ /dev/null @@ -1,108 +0,0 @@ -from typing import Literal, Optional, Union - -from ninja import Field, Schema - - -class Application(Schema): - id: str - name: str - website: str | None - client_id: str - client_secret: str - redirect_uri: str = Field(alias="redirect_uris") - - -class CustomEmoji(Schema): - shortcode: str - url: str - static_url: str - visible_in_picker: bool - category: str - - -class AccountField(Schema): - name: str - value: str - verified_at: str | None - - -class Account(Schema): - id: str - username: str - acct: str - url: str - display_name: str - note: str - avatar: str - avatar_static: str - header: str - header_static: str - locked: bool - fields: list[AccountField] - emojis: list[CustomEmoji] - bot: bool - group: bool - discoverable: bool - moved: Union[None, bool, "Account"] - suspended: bool - limited: bool - created_at: str - last_status_at: str | None = Field(...) - statuses_count: int - followers_count: int - following_count: int - - -class MediaAttachment(Schema): - id: str - type: Literal["unknown", "image", "gifv", "video", "audio"] - url: str - preview_url: str - remote_url: str | None - meta: dict - description: str | None - blurhash: str | None - - -class StatusMention(Schema): - id: str - username: str - url: str - acct: str - - -class StatusTag(Schema): - name: str - url: str - - -class Status(Schema): - id: str - uri: str - created_at: str - account: Account - content: str - visibility: Literal["public", "unlisted", "private", "direct"] - sensitive: bool - spoiler_text: str - media_attachments: list[MediaAttachment] - mentions: list[StatusMention] - tags: list[StatusTag] - emojis: list[CustomEmoji] - reblogs_count: int - favourites_count: int - replies_count: int - url: str | None = Field(...) - in_reply_to_id: str | None = Field(...) - in_reply_to_account_id: str | None = Field(...) - reblog: Optional["Status"] = Field(...) - poll: None = Field(...) - card: None = Field(...) - language: None = Field(...) - text: str | None = Field(...) - edited_at: str | None - favourited: bool | None - reblogged: bool | None - muted: bool | None - bookmarked: bool | None - pinned: bool | None diff --git a/api/views/__init__.py b/api/views/__init__.py index 93cf419..c6dc765 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -1,6 +1,8 @@ from .accounts import * # noqa from .apps import * # noqa -from .base import api # noqa +from .base import api_router # noqa from .instance import * # noqa +from .notifications import * # noqa from .oauth import * # noqa +from .search import * # noqa from .timelines import * # noqa diff --git a/api/views/accounts.py b/api/views/accounts.py index 79906dc..1b883e8 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -1,9 +1,94 @@ -from .. import schemas +from django.shortcuts import get_object_or_404 + +from activities.models import Post +from api import schemas +from api.views.base import api_router +from users.models import Identity + from ..decorators import identity_required -from .base import api -@api.get("/v1/accounts/verify_credentials", response=schemas.Account) +@api_router.get("/v1/accounts/verify_credentials", response=schemas.Account) @identity_required def verify_credentials(request): return request.identity.to_mastodon_json() + + +@api_router.get("/v1/accounts/relationships", response=list[schemas.Relationship]) +@identity_required +def account_relationships(request): + ids = request.GET.getlist("id[]") + result = [] + for id in ids: + identity = get_object_or_404(Identity, pk=id) + result.append( + { + "id": identity.pk, + "following": identity.inbound_follows.filter( + source=request.identity + ).exists(), + "followed_by": identity.outbound_follows.filter( + target=request.identity + ).exists(), + "showing_reblogs": True, + "notifying": False, + "blocking": False, + "blocked_by": False, + "muting": False, + "muting_notifications": False, + "requested": False, + "domain_blocking": False, + "endorsed": False, + "note": "", + } + ) + return result + + +@api_router.get("/v1/accounts/{id}", response=schemas.Account) +@identity_required +def account(request, id: str): + identity = get_object_or_404(Identity, pk=id) + return identity.to_mastodon_json() + + +@api_router.get("/v1/accounts/{id}/statuses", response=list[schemas.Status]) +@identity_required +def account_statuses( + request, + id: str, + exclude_reblogs: bool = False, + exclude_replies: bool = False, + only_media: bool = False, + pinned: bool = False, + tagged: str | None = None, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +): + identity = get_object_or_404(Identity, pk=id) + posts = ( + identity.posts.public() + .select_related("author") + .prefetch_related("attachments") + .order_by("-created") + ) + if pinned: + return [] + if only_media: + posts = posts.filter(attachments__pk__isnull=False) + if tagged: + posts = posts.tagged_with(tagged) + if max_id: + anchor_post = Post.objects.get(pk=max_id) + posts = posts.filter(created__lt=anchor_post.created) + if since_id: + anchor_post = Post.objects.get(pk=since_id) + posts = posts.filter(created__gt=anchor_post.created) + if min_id: + # Min ID requires LIMIT posts _immediately_ newer than specified, so we + # invert the ordering to accomodate + anchor_post = Post.objects.get(pk=min_id) + posts = posts.filter(created__gt=anchor_post.created).order_by("created") + return [post.to_mastodon_json() for post in posts[:limit]] diff --git a/api/views/apps.py b/api/views/apps.py index 1642ee9..74d8ac8 100644 --- a/api/views/apps.py +++ b/api/views/apps.py @@ -4,7 +4,7 @@ from ninja import Schema from .. import schemas from ..models import Application -from .base import api +from .base import api_router class CreateApplicationSchema(Schema): @@ -14,7 +14,7 @@ class CreateApplicationSchema(Schema): website: None | str = None -@api.post("/v1/apps", response=schemas.Application) +@api_router.post("/v1/apps", response=schemas.Application) def add_app(request, details: CreateApplicationSchema): client_id = "tk-" + secrets.token_urlsafe(16) client_secret = secrets.token_urlsafe(40) diff --git a/api/views/base.py b/api/views/base.py index e9a087d..33efc47 100644 --- a/api/views/base.py +++ b/api/views/base.py @@ -2,4 +2,4 @@ from ninja import NinjaAPI from api.parser import FormOrJsonParser -api = NinjaAPI(parser=FormOrJsonParser()) +api_router = NinjaAPI(parser=FormOrJsonParser()) diff --git a/api/views/instance.py b/api/views/instance.py index eef258d..45de4a6 100644 --- a/api/views/instance.py +++ b/api/views/instance.py @@ -5,10 +5,10 @@ from core.models import Config from takahe import __version__ from users.models import Domain, Identity -from .base import api +from .base import api_router -@api.get("/v1/instance") +@api_router.get("/v1/instance") def instance_info(request): return { "uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN), @@ -16,7 +16,7 @@ def instance_info(request): "short_description": "", "description": "", "email": "", - "version": __version__, + "version": f"takahe/{__version__}", "urls": {}, "stats": { "user_count": Identity.objects.filter(local=True).count(), diff --git a/api/views/notifications.py b/api/views/notifications.py new file mode 100644 index 0000000..9ccda81 --- /dev/null +++ b/api/views/notifications.py @@ -0,0 +1,52 @@ +from activities.models import TimelineEvent + +from .. import schemas +from ..decorators import identity_required +from .base import api_router + + +@api_router.get("/v1/notifications", response=list[schemas.Notification]) +@identity_required +def notifications( + request, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, + account_id: str | None = None, +): + if limit > 40: + limit = 40 + # Types/exclude_types use weird syntax so we have to handle them manually + base_types = { + "favourite": TimelineEvent.Types.liked, + "reblog": TimelineEvent.Types.boosted, + "mention": TimelineEvent.Types.mentioned, + "follow": TimelineEvent.Types.followed, + } + requested_types = set(request.GET.getlist("types[]")) + excluded_types = set(request.GET.getlist("exclude_types[]")) + if not requested_types: + requested_types = set(base_types.keys()) + requested_types.difference_update(excluded_types) + # Use that to pull relevant events + events = ( + TimelineEvent.objects.filter( + identity=request.identity, + type__in=[base_types[r] for r in requested_types], + ) + .order_by("-created") + .select_related("subject_post", "subject_post__author", "subject_identity") + ) + if max_id: + anchor_event = TimelineEvent.objects.get(pk=max_id) + events = events.filter(created__lt=anchor_event.created) + if since_id: + anchor_event = TimelineEvent.objects.get(pk=since_id) + events = events.filter(created__gt=anchor_event.created) + if min_id: + # Min ID requires LIMIT events _immediately_ newer than specified, so we + # invert the ordering to accomodate + anchor_event = TimelineEvent.objects.get(pk=min_id) + events = events.filter(created__gt=anchor_event.created).order_by("created") + return [event.to_mastodon_notification_json() for event in events[:limit]] diff --git a/api/views/search.py b/api/views/search.py new file mode 100644 index 0000000..7735a65 --- /dev/null +++ b/api/views/search.py @@ -0,0 +1,42 @@ +from typing import Literal + +from ninja import Field + +from activities.search import Searcher +from api import schemas +from api.decorators import identity_required +from api.views.base import api_router + + +@api_router.get("/v2/search", response=schemas.Search) +@identity_required +def search( + request, + q: str, + type: Literal["accounts", "hashtags", "statuses"] | None = None, + fetch_identities: bool = Field(False, alias="resolve"), + following: bool = False, + exclude_unreviewed: bool = False, + account_id: str | None = None, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, + offset: int = 0, +): + if limit > 40: + limit = 40 + result: dict[str, list] = {"accounts": [], "statuses": [], "hashtags": []} + # We don't support pagination for searches yet + if max_id or since_id or min_id or offset: + return result + # Run search + searcher = Searcher(q, request.identity) + search_result = searcher.search_all() + if type is None or type == "accounts": + result["accounts"] = [i.to_mastodon_json() for i in search_result["identities"]] + if type is None or type == "hashtag": + result["hashtag"] = [h.to_mastodon_json() for h in search_result["hashtags"]] + if type is None or type == "statuses": + result["statuses"] = [p.to_mastodon_json() for p in search_result["posts"]] + return result diff --git a/api/views/timelines.py b/api/views/timelines.py index 5de0e0f..d560596 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,16 +1,21 @@ -from activities.models import TimelineEvent +from activities.models import Post, TimelineEvent from .. import schemas from ..decorators import identity_required -from .base import api +from .base import api_router -@api.get("/v1/timelines/home", response=list[schemas.Status]) +@api_router.get("/v1/timelines/home", response=list[schemas.Status]) @identity_required -def home(request): - if request.GET.get("max_id"): - return [] - limit = int(request.GET.get("limit", "20")) +def home( + request, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +): + if limit > 40: + limit = 40 events = ( TimelineEvent.objects.filter( identity=request.identity, @@ -18,6 +23,109 @@ def home(request): ) .select_related("subject_post", "subject_post__author") .prefetch_related("subject_post__attachments") - .order_by("-created")[:limit] + .order_by("-created") ) - return [event.subject_post.to_mastodon_json() for event in events] + if max_id: + anchor_post = Post.objects.get(pk=max_id) + events = events.filter(created__lt=anchor_post.created) + if since_id: + anchor_post = Post.objects.get(pk=since_id) + events = events.filter(created__gt=anchor_post.created) + if min_id: + # Min ID requires LIMIT events _immediately_ newer than specified, so we + # invert the ordering to accomodate + anchor_post = Post.objects.get(pk=min_id) + events = events.filter(created__gt=anchor_post.created).order_by("created") + return [event.subject_post.to_mastodon_json() for event in events[:limit]] + + +@api_router.get("/v1/timelines/public", response=list[schemas.Status]) +@identity_required +def public( + request, + local: bool = False, + remote: bool = False, + only_media: bool = False, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +): + if limit > 40: + limit = 40 + posts = ( + Post.objects.public() + .select_related("author") + .prefetch_related("attachments") + .order_by("-created") + ) + if local: + posts = posts.filter(local=True) + elif remote: + posts = posts.filter(local=False) + if only_media: + posts = posts.filter(attachments__id__isnull=True) + if max_id: + anchor_post = Post.objects.get(pk=max_id) + posts = posts.filter(created__lt=anchor_post.created) + if since_id: + anchor_post = Post.objects.get(pk=since_id) + posts = posts.filter(created__gt=anchor_post.created) + if min_id: + # Min ID requires LIMIT posts _immediately_ newer than specified, so we + # invert the ordering to accomodate + anchor_post = Post.objects.get(pk=min_id) + posts = posts.filter(created__gt=anchor_post.created).order_by("created") + return [post.to_mastodon_json() for post in posts[:limit]] + + +@api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status]) +@identity_required +def hashtag( + request, + hashtag: str, + local: bool = False, + only_media: bool = False, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +): + if limit > 40: + limit = 40 + posts = ( + Post.objects.public() + .tagged_with(hashtag) + .select_related("author") + .prefetch_related("attachments") + .order_by("-created") + ) + if local: + posts = posts.filter(local=True) + if only_media: + posts = posts.filter(attachments__id__isnull=True) + if max_id: + anchor_post = Post.objects.get(pk=max_id) + posts = posts.filter(created__lt=anchor_post.created) + if since_id: + anchor_post = Post.objects.get(pk=since_id) + posts = posts.filter(created__gt=anchor_post.created) + if min_id: + # Min ID requires LIMIT posts _immediately_ newer than specified, so we + # invert the ordering to accomodate + anchor_post = Post.objects.get(pk=min_id) + posts = posts.filter(created__gt=anchor_post.created).order_by("created") + return [post.to_mastodon_json() for post in posts[:limit]] + + +@api_router.get("/v1/conversations", response=list[schemas.Status]) +@identity_required +def conversations( + request, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +): + # We don't implement this yet + return [] -- cgit v1.2.3