diff options
author | Andrew Godwin | 2022-12-11 11:22:06 -0700 |
---|---|---|
committer | Andrew Godwin | 2022-12-12 11:56:49 -0700 |
commit | fc8a21fc5c6809ea115092eeec57e09e984cdd76 (patch) | |
tree | 9ca40c9d9b192040875b9442c965e855df3bd052 /api | |
parent | 3e062aed360ca54c26733b175d00d0d4671f3591 (diff) | |
download | takahe-fc8a21fc5c6809ea115092eeec57e09e984cdd76.tar.gz takahe-fc8a21fc5c6809ea115092eeec57e09e984cdd76.tar.bz2 takahe-fc8a21fc5c6809ea115092eeec57e09e984cdd76.zip |
More API read coverage
Diffstat (limited to 'api')
-rw-r--r-- | api/schemas.py (renamed from api/schemas/__init__.py) | 54 | ||||
-rw-r--r-- | api/views/__init__.py | 4 | ||||
-rw-r--r-- | api/views/accounts.py | 91 | ||||
-rw-r--r-- | api/views/apps.py | 4 | ||||
-rw-r--r-- | api/views/base.py | 2 | ||||
-rw-r--r-- | api/views/instance.py | 6 | ||||
-rw-r--r-- | api/views/notifications.py | 52 | ||||
-rw-r--r-- | api/views/search.py | 42 | ||||
-rw-r--r-- | api/views/timelines.py | 126 |
9 files changed, 362 insertions, 19 deletions
diff --git a/api/schemas/__init__.py b/api/schemas.py index cc0660c..a8f4e45 100644 --- a/api/schemas/__init__.py +++ b/api/schemas.py @@ -106,3 +106,57 @@ class Status(Schema): 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/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 [] |