From 0dca7eae5fb401f1330114f09e1786d9df73f489 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 19 Dec 2022 20:54:09 +0000 Subject: [PATCH] Implement API follow/unfollow Fixes #198 --- api/views/accounts.py | 43 ++++++++++++----------- users/services/__init__.py | 1 + users/services/identity.py | 70 ++++++++++++++++++++++++++++++++++++++ users/views/identity.py | 14 ++------ 4 files changed, 98 insertions(+), 30 deletions(-) create mode 100644 users/services/__init__.py create mode 100644 users/services/identity.py diff --git a/api/views/accounts.py b/api/views/accounts.py index d0aeb08..47d91c6 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -6,6 +6,7 @@ from api.decorators import identity_required from api.pagination import MastodonPaginator from api.views.base import api_router from users.models import Identity +from users.services import IdentityService @api_router.get("/v1/accounts/verify_credentials", response=schemas.Account) @@ -22,25 +23,7 @@ def account_relationships(request): 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": "", - } + IdentityService(identity).mastodon_json_relationship(request.identity) ) return result @@ -95,3 +78,25 @@ def account_statuses( ) interactions = PostInteraction.get_post_interactions(posts, request.identity) return [post.to_mastodon_json(interactions=interactions) for post in queryset] + + +@api_router.post("/v1/accounts/{id}/follow", response=schemas.Relationship) +@identity_required +def account_follow(request, id: str): + identity = get_object_or_404( + Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id + ) + service = IdentityService(identity) + service.follow_from(request.identity) + return service.mastodon_json_relationship(request.identity) + + +@api_router.post("/v1/accounts/{id}/unfollow", response=schemas.Relationship) +@identity_required +def account_unfollow(request, id: str): + identity = get_object_or_404( + Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id + ) + service = IdentityService(identity) + service.unfollow_from(request.identity) + return service.mastodon_json_relationship(request.identity) diff --git a/users/services/__init__.py b/users/services/__init__.py new file mode 100644 index 0000000..ff392fb --- /dev/null +++ b/users/services/__init__.py @@ -0,0 +1 @@ +from .identity import IdentityService # noqa diff --git a/users/services/identity.py b/users/services/identity.py new file mode 100644 index 0000000..867a2c3 --- /dev/null +++ b/users/services/identity.py @@ -0,0 +1,70 @@ +from typing import cast + +from users.models import Follow, FollowStates, Identity + + +class IdentityService: + """ + High-level helper methods for doing things to identities + """ + + def __init__(self, identity: Identity): + self.identity = identity + + def follow_from(self, from_identity: Identity) -> Follow: + """ + Follows a user (or does nothing if already followed). + Returns the follow. + """ + existing_follow = Follow.maybe_get(from_identity, self.identity) + if not existing_follow: + Follow.create_local(from_identity, self.identity) + elif existing_follow.state in [ + FollowStates.undone, + FollowStates.undone_remotely, + ]: + existing_follow.transition_perform(FollowStates.unrequested) + return cast(Follow, existing_follow) + + def unfollow_from(self, from_identity: Identity): + """ + Unfollows a user (or does nothing if not followed). + """ + existing_follow = Follow.maybe_get(from_identity, self.identity) + if existing_follow: + existing_follow.transition_perform(FollowStates.undone) + + def mastodon_json_relationship(self, from_identity: Identity): + """ + Returns a Relationship object for the from_identity's relationship + with this identity. + """ + return { + "id": self.identity.pk, + "following": self.identity.inbound_follows.filter(source=from_identity) + .exclude( + state__in=[ + FollowStates.undone, + FollowStates.undone_remotely, + ] + ) + .exists(), + "followed_by": self.identity.outbound_follows.filter(target=from_identity) + .exclude( + state__in=[ + FollowStates.undone, + FollowStates.undone_remotely, + ] + ) + .exists(), + "showing_reblogs": True, + "notifying": False, + "blocking": False, + "blocked_by": False, + "muting": False, + "muting_notifications": False, + "requested": False, + "domain_blocking": False, + "endorsed": False, + "note": "", + } diff --git a/users/views/identity.py b/users/views/identity.py index 268b683..0a612a5 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -16,6 +16,7 @@ from core.ld import canonicalise from core.models import Config from users.decorators import identity_required from users.models import Domain, Follow, FollowStates, Identity, IdentityStates +from users.services import IdentityService from users.shortcuts import by_handle_or_404 @@ -146,18 +147,9 @@ class ActionIdentity(View): # See what action we should perform action = self.request.POST["action"] if action == "follow": - existing_follow = Follow.maybe_get(self.request.identity, identity) - if not existing_follow: - Follow.create_local(self.request.identity, identity) - elif existing_follow.state in [ - FollowStates.undone, - FollowStates.undone_remotely, - ]: - existing_follow.transition_perform(FollowStates.unrequested) + IdentityService(identity).follow_from(self.request.identity) elif action == "unfollow": - existing_follow = Follow.maybe_get(self.request.identity, identity) - if existing_follow: - existing_follow.transition_perform(FollowStates.undone) + IdentityService(identity).unfollow_from(self.request.identity) else: raise ValueError(f"Cannot handle identity action {action}") return redirect(identity.urls.view)