mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 00:30:59 +00:00
Support follow requests (#625)
This commit is contained in:
parent
faa181807c
commit
70b9e3b900
11 changed files with 283 additions and 99 deletions
|
@ -324,6 +324,7 @@ class Migration(migrations.Migration):
|
|||
("mentioned", "Mentioned"),
|
||||
("liked", "Liked"),
|
||||
("followed", "Followed"),
|
||||
("follow_requested", "Follow Requested"),
|
||||
("boosted", "Boosted"),
|
||||
("announcement", "Announcement"),
|
||||
("identity_created", "Identity Created"),
|
||||
|
|
|
@ -16,6 +16,7 @@ class TimelineEvent(models.Model):
|
|||
mentioned = "mentioned"
|
||||
liked = "liked" # Someone liking one of our posts
|
||||
followed = "followed"
|
||||
follow_requested = "follow_requested"
|
||||
boosted = "boosted" # Someone boosting one of our posts
|
||||
announcement = "announcement" # Server announcement
|
||||
identity_created = "identity_created" # New identity created
|
||||
|
@ -74,14 +75,30 @@ class TimelineEvent(models.Model):
|
|||
@classmethod
|
||||
def add_follow(cls, identity, source_identity):
|
||||
"""
|
||||
Adds a follow to the timeline if it's not there already
|
||||
Adds a follow to the timeline if it's not there already, remove follow request if any
|
||||
"""
|
||||
cls.objects.filter(
|
||||
type=cls.Types.follow_requested,
|
||||
identity=identity,
|
||||
subject_identity=source_identity,
|
||||
).delete()
|
||||
return cls.objects.get_or_create(
|
||||
identity=identity,
|
||||
type=cls.Types.followed,
|
||||
subject_identity=source_identity,
|
||||
)[0]
|
||||
|
||||
@classmethod
|
||||
def add_follow_request(cls, identity, source_identity):
|
||||
"""
|
||||
Adds a follow request to the timeline if it's not there already
|
||||
"""
|
||||
return cls.objects.get_or_create(
|
||||
identity=identity,
|
||||
type=cls.Types.follow_requested,
|
||||
subject_identity=source_identity,
|
||||
)[0]
|
||||
|
||||
@classmethod
|
||||
def add_post(cls, identity, post):
|
||||
"""
|
||||
|
@ -169,6 +186,14 @@ class TimelineEvent(models.Model):
|
|||
subject_identity_id=interaction.identity_id,
|
||||
).delete()
|
||||
|
||||
@classmethod
|
||||
def delete_follow(cls, target, source):
|
||||
TimelineEvent.objects.filter(
|
||||
type__in=[cls.Types.followed, cls.Types.follow_requested],
|
||||
identity=target,
|
||||
subject_identity=source,
|
||||
).delete()
|
||||
|
||||
### Background tasks ###
|
||||
|
||||
@classmethod
|
||||
|
@ -218,6 +243,8 @@ class TimelineEvent(models.Model):
|
|||
)
|
||||
elif self.type == self.Types.followed:
|
||||
result["type"] = "follow"
|
||||
elif self.type == self.Types.follow_requested:
|
||||
result["type"] = "follow_request"
|
||||
elif self.type == self.Types.identity_created:
|
||||
result["type"] = "admin.sign_up"
|
||||
else:
|
||||
|
|
|
@ -58,6 +58,8 @@ urlpatterns = [
|
|||
path("v1/filters", filters.list_filters),
|
||||
# Follow requests
|
||||
path("v1/follow_requests", follow_requests.follow_requests),
|
||||
path("v1/follow_requests/<id>/authorize", follow_requests.accept_follow_request),
|
||||
path("v1/follow_requests/<id>/reject", follow_requests.reject_follow_request),
|
||||
# Instance
|
||||
path("v1/instance", instance.instance_info_v1),
|
||||
path("v1/instance/activity", instance.activity),
|
||||
|
|
|
@ -29,6 +29,7 @@ def update_credentials(
|
|||
display_name: QueryOrBody[str | None] = None,
|
||||
note: QueryOrBody[str | None] = None,
|
||||
discoverable: QueryOrBody[bool | None] = None,
|
||||
locked: QueryOrBody[bool | None] = None,
|
||||
source: QueryOrBody[dict[str, Any] | None] = None,
|
||||
fields_attributes: QueryOrBody[dict[str, dict[str, str]] | None] = None,
|
||||
avatar: File | None = None,
|
||||
|
@ -42,6 +43,8 @@ def update_credentials(
|
|||
service.set_summary(note)
|
||||
if discoverable is not None:
|
||||
identity.discoverable = discoverable
|
||||
if locked is not None:
|
||||
identity.manually_approves_followers = locked
|
||||
if source:
|
||||
if "privacy" in source:
|
||||
privacy_map = {
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from hatchway import api_view
|
||||
|
||||
from api import schemas
|
||||
from api.decorators import scope_required
|
||||
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
|
||||
from users.models.identity import Identity
|
||||
from users.services.identity import IdentityService
|
||||
|
||||
|
||||
@scope_required("read:follows")
|
||||
|
@ -14,5 +18,43 @@ def follow_requests(
|
|||
min_id: str | None = None,
|
||||
limit: int = 40,
|
||||
) -> list[schemas.Account]:
|
||||
# We don't implement this yet
|
||||
return []
|
||||
service = IdentityService(request.identity)
|
||||
paginator = MastodonPaginator(max_limit=80)
|
||||
pager: PaginationResult[Identity] = paginator.paginate(
|
||||
service.follow_requests(),
|
||||
min_id=min_id,
|
||||
max_id=max_id,
|
||||
since_id=since_id,
|
||||
limit=limit,
|
||||
)
|
||||
return PaginatingApiResponse(
|
||||
[schemas.Account.from_identity(i) for i in pager.results],
|
||||
request=request,
|
||||
include_params=["limit"],
|
||||
)
|
||||
|
||||
|
||||
@scope_required("write:follows")
|
||||
@api_view.post
|
||||
def accept_follow_request(
|
||||
request: HttpRequest,
|
||||
id: str | None = None,
|
||||
) -> schemas.Relationship:
|
||||
source_identity = get_object_or_404(
|
||||
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
|
||||
)
|
||||
IdentityService(request.identity).accept_follow_request(source_identity)
|
||||
return IdentityService(source_identity).mastodon_json_relationship(request.identity)
|
||||
|
||||
|
||||
@scope_required("write:follows")
|
||||
@api_view.post
|
||||
def reject_follow_request(
|
||||
request: HttpRequest,
|
||||
id: str | None = None,
|
||||
) -> schemas.Relationship:
|
||||
source_identity = get_object_or_404(
|
||||
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
|
||||
)
|
||||
IdentityService(request.identity).reject_follow_request(source_identity)
|
||||
return IdentityService(source_identity).mastodon_json_relationship(request.identity)
|
||||
|
|
|
@ -251,7 +251,8 @@ def test_clear_timeline(
|
|||
else:
|
||||
service.unfollow(remote_identity)
|
||||
|
||||
# Run stator once to process the timeline clear message
|
||||
# Run stator twice to process the timeline clear message
|
||||
stator.run_single_cycle()
|
||||
stator.run_single_cycle()
|
||||
|
||||
# Verify that the right things vanished
|
||||
|
|
|
@ -33,7 +33,7 @@ def test_follow(
|
|||
assert outbound_data["actor"] == identity.actor_uri
|
||||
assert outbound_data["object"] == remote_identity.actor_uri
|
||||
assert outbound_data["id"] == f"{identity.actor_uri}follow/{follow.pk}/"
|
||||
assert Follow.objects.get(pk=follow.pk).state == FollowStates.local_requested
|
||||
assert Follow.objects.get(pk=follow.pk).state == FollowStates.pending_approval
|
||||
# Come in with an inbox message of either a reference type or an embedded type
|
||||
if ref_only:
|
||||
message = {
|
||||
|
@ -53,4 +53,5 @@ def test_follow(
|
|||
InboxMessage.objects.create(message=message)
|
||||
# Run stator and ensure that accepted our follow
|
||||
stator.run_single_cycle()
|
||||
stator.run_single_cycle()
|
||||
assert Follow.objects.get(pk=follow.pk).state == FollowStates.accepted
|
||||
|
|
22
users/migrations/0022_follow_request.py
Normal file
22
users/migrations/0022_follow_request.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 4.2.3 on 2023-08-04 01:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0021_identity_aliases"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"UPDATE users_follow SET state = 'pending_approval' WHERE state = 'local_requested';"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE users_follow SET state = 'accepting' WHERE state = 'remote_requested';"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"DELETE FROM users_follow WHERE state not in ('accepted', 'accepting', 'pending_approval', 'unrequested');"
|
||||
),
|
||||
]
|
|
@ -7,92 +7,140 @@ from core.exceptions import capture_message
|
|||
from core.ld import canonicalise, get_str_or_id
|
||||
from core.snowflake import Snowflake
|
||||
from stator.models import State, StateField, StateGraph, StatorModel
|
||||
from users.models.block import Block
|
||||
from users.models.identity import Identity
|
||||
from users.models.inbox_message import InboxMessage
|
||||
|
||||
|
||||
class FollowStates(StateGraph):
|
||||
unrequested = State(try_interval=600)
|
||||
local_requested = State(try_interval=24 * 60 * 60)
|
||||
remote_requested = State(try_interval=24 * 60 * 60)
|
||||
pending_approval = State(externally_progressed=True)
|
||||
accepting = State(try_interval=24 * 60 * 60)
|
||||
rejecting = State(try_interval=24 * 60 * 60)
|
||||
accepted = State(externally_progressed=True)
|
||||
undone = State(try_interval=60 * 60)
|
||||
undone_remotely = State(delete_after=24 * 60 * 60)
|
||||
failed = State()
|
||||
rejected = State()
|
||||
undone = State(try_interval=24 * 60 * 60)
|
||||
pending_removal = State(try_interval=60 * 60)
|
||||
removed = State(delete_after=1)
|
||||
|
||||
unrequested.transitions_to(local_requested)
|
||||
unrequested.transitions_to(remote_requested)
|
||||
unrequested.times_out_to(failed, seconds=86400 * 7)
|
||||
local_requested.transitions_to(accepted)
|
||||
local_requested.transitions_to(rejected)
|
||||
remote_requested.transitions_to(accepted)
|
||||
unrequested.transitions_to(pending_approval)
|
||||
unrequested.transitions_to(accepting)
|
||||
unrequested.transitions_to(rejecting)
|
||||
unrequested.times_out_to(removed, seconds=24 * 60 * 60)
|
||||
pending_approval.transitions_to(accepting)
|
||||
pending_approval.transitions_to(rejecting)
|
||||
pending_approval.transitions_to(pending_removal)
|
||||
accepting.transitions_to(accepted)
|
||||
accepting.times_out_to(accepted, seconds=7 * 24 * 60 * 60)
|
||||
rejecting.transitions_to(pending_removal)
|
||||
rejecting.times_out_to(pending_removal, seconds=24 * 60 * 60)
|
||||
accepted.transitions_to(rejecting)
|
||||
accepted.transitions_to(undone)
|
||||
undone.transitions_to(undone_remotely)
|
||||
undone.transitions_to(pending_removal)
|
||||
pending_removal.transitions_to(removed)
|
||||
|
||||
@classmethod
|
||||
def group_active(cls):
|
||||
return [cls.unrequested, cls.local_requested, cls.accepted]
|
||||
"""
|
||||
Follows that are active means they are being handled and no need to re-request
|
||||
"""
|
||||
return [cls.unrequested, cls.pending_approval, cls.accepting, cls.accepted]
|
||||
|
||||
@classmethod
|
||||
def group_accepted(cls):
|
||||
"""
|
||||
Follows that are accepting/accepted means they should be consider accepted when deliver to followers
|
||||
"""
|
||||
return [cls.accepting, cls.accepted]
|
||||
|
||||
@classmethod
|
||||
def handle_unrequested(cls, instance: "Follow"):
|
||||
"""
|
||||
Follows that are unrequested need us to deliver the Follow object
|
||||
to the target server.
|
||||
Follows start unrequested as their initial state regardless of local/remote
|
||||
"""
|
||||
# Remote follows should not be here
|
||||
if Block.maybe_get(
|
||||
source=instance.target, target=instance.source, require_active=True
|
||||
):
|
||||
return cls.rejecting
|
||||
if not instance.target.local:
|
||||
if not instance.source.local:
|
||||
# remote follow remote, invalid case
|
||||
return cls.removed
|
||||
# local follow remote, send Follow to target server
|
||||
# Don't try if the other identity didn't fetch yet
|
||||
if not instance.target.inbox_uri:
|
||||
return
|
||||
# Sign it and send it
|
||||
try:
|
||||
instance.source.signed_request(
|
||||
method="post",
|
||||
uri=instance.target.inbox_uri,
|
||||
body=canonicalise(instance.to_ap()),
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return
|
||||
return cls.pending_approval
|
||||
# local/remote follow local, check manually_approve
|
||||
if instance.target.manually_approves_followers:
|
||||
from activities.models import TimelineEvent
|
||||
|
||||
TimelineEvent.add_follow_request(instance.target, instance.source)
|
||||
return cls.pending_approval
|
||||
return cls.accepting
|
||||
|
||||
@classmethod
|
||||
def handle_accepting(cls, instance: "Follow"):
|
||||
if not instance.source.local:
|
||||
return cls.remote_requested
|
||||
if instance.target.local:
|
||||
return cls.accepted
|
||||
# Don't try if the other identity didn't fetch yet
|
||||
if not instance.target.inbox_uri:
|
||||
return
|
||||
# Sign it and send it
|
||||
try:
|
||||
instance.source.signed_request(
|
||||
method="post",
|
||||
uri=instance.target.inbox_uri,
|
||||
body=canonicalise(instance.to_ap()),
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return
|
||||
return cls.local_requested
|
||||
# send an Accept object to the source server
|
||||
try:
|
||||
instance.target.signed_request(
|
||||
method="post",
|
||||
uri=instance.source.inbox_uri,
|
||||
body=canonicalise(instance.to_accept_ap()),
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return
|
||||
from activities.models import TimelineEvent
|
||||
|
||||
@classmethod
|
||||
def handle_local_requested(cls, instance: "Follow"):
|
||||
# TODO: Resend follow requests occasionally
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def handle_remote_requested(cls, instance: "Follow"):
|
||||
"""
|
||||
Items in remote_requested need us to send an Accept object to the
|
||||
source server.
|
||||
"""
|
||||
try:
|
||||
instance.target.signed_request(
|
||||
method="post",
|
||||
uri=instance.source.inbox_uri,
|
||||
body=canonicalise(instance.to_accept_ap()),
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return
|
||||
TimelineEvent.add_follow(instance.target, instance.source)
|
||||
return cls.accepted
|
||||
|
||||
@classmethod
|
||||
def handle_rejecting(cls, instance: "Follow"):
|
||||
if not instance.source.local:
|
||||
# send a Reject object to the source server
|
||||
try:
|
||||
instance.target.signed_request(
|
||||
method="post",
|
||||
uri=instance.source.inbox_uri,
|
||||
body=canonicalise(instance.to_reject_ap()),
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return
|
||||
return cls.pending_removal
|
||||
|
||||
@classmethod
|
||||
def handle_undone(cls, instance: "Follow"):
|
||||
"""
|
||||
Delivers the Undo object to the target server
|
||||
"""
|
||||
try:
|
||||
instance.source.signed_request(
|
||||
method="post",
|
||||
uri=instance.target.inbox_uri,
|
||||
body=canonicalise(instance.to_undo_ap()),
|
||||
)
|
||||
if not instance.target.local:
|
||||
instance.source.signed_request(
|
||||
method="post",
|
||||
uri=instance.target.inbox_uri,
|
||||
body=canonicalise(instance.to_undo_ap()),
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return
|
||||
return cls.undone_remotely
|
||||
return cls.pending_removal
|
||||
|
||||
@classmethod
|
||||
def handle_pending_removal(cls, instance: "Follow"):
|
||||
if instance.target.local:
|
||||
from activities.models import TimelineEvent
|
||||
|
||||
TimelineEvent.delete_follow(instance.target, instance.source)
|
||||
return cls.removed
|
||||
|
||||
|
||||
class FollowQuerySet(models.QuerySet):
|
||||
|
@ -100,6 +148,10 @@ class FollowQuerySet(models.QuerySet):
|
|||
query = self.filter(state__in=FollowStates.group_active())
|
||||
return query
|
||||
|
||||
def accepted(self):
|
||||
query = self.filter(state__in=FollowStates.group_accepted())
|
||||
return query
|
||||
|
||||
|
||||
class FollowManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
|
@ -108,6 +160,9 @@ class FollowManager(models.Manager):
|
|||
def active(self):
|
||||
return self.get_queryset().active()
|
||||
|
||||
def accepted(self):
|
||||
return self.get_queryset().accepted()
|
||||
|
||||
|
||||
class Follow(StatorModel):
|
||||
"""
|
||||
|
@ -169,16 +224,13 @@ class Follow(StatorModel):
|
|||
Creates a Follow from a local Identity to the target
|
||||
(which can be local or remote).
|
||||
"""
|
||||
from activities.models import TimelineEvent
|
||||
|
||||
if not source.local:
|
||||
raise ValueError("You cannot initiate follows from a remote Identity")
|
||||
try:
|
||||
follow = Follow.objects.get(source=source, target=target)
|
||||
if not follow.active:
|
||||
follow.state = (
|
||||
FollowStates.accepted if target.local else FollowStates.unrequested
|
||||
)
|
||||
follow.state = FollowStates.unrequested
|
||||
follow.boosts = boosts
|
||||
follow.save()
|
||||
except Follow.DoesNotExist:
|
||||
|
@ -188,29 +240,22 @@ class Follow(StatorModel):
|
|||
target=target,
|
||||
boosts=boosts,
|
||||
uri="",
|
||||
state=(
|
||||
FollowStates.accepted
|
||||
if target.local
|
||||
else FollowStates.unrequested
|
||||
),
|
||||
state=FollowStates.unrequested,
|
||||
)
|
||||
follow.uri = source.actor_uri + f"follow/{follow.pk}/"
|
||||
# TODO: Local follow approvals
|
||||
if target.local:
|
||||
TimelineEvent.add_follow(follow.target, follow.source)
|
||||
follow.save()
|
||||
return follow
|
||||
|
||||
### Properties ###
|
||||
|
||||
@property
|
||||
def pending(self):
|
||||
return self.state in [FollowStates.unrequested, FollowStates.local_requested]
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
return self.state in FollowStates.group_active()
|
||||
|
||||
@property
|
||||
def accepted(self):
|
||||
return self.state in FollowStates.group_accepted()
|
||||
|
||||
### ActivityPub (outbound) ###
|
||||
|
||||
def to_ap(self):
|
||||
|
@ -235,6 +280,17 @@ class Follow(StatorModel):
|
|||
"object": self.to_ap(),
|
||||
}
|
||||
|
||||
def to_reject_ap(self):
|
||||
"""
|
||||
Returns the AP JSON for this objects' rejection.
|
||||
"""
|
||||
return {
|
||||
"type": "Reject",
|
||||
"id": self.uri + "#reject",
|
||||
"actor": self.target.actor_uri,
|
||||
"object": self.to_ap(),
|
||||
}
|
||||
|
||||
def to_undo_ap(self):
|
||||
"""
|
||||
Returns the AP JSON for this objects' undo.
|
||||
|
@ -268,14 +324,14 @@ class Follow(StatorModel):
|
|||
source = Identity.by_actor_uri(data["actor"], create=create)
|
||||
target = Identity.by_actor_uri(get_str_or_id(data["object"]))
|
||||
follow = cls.maybe_get(source=source, target=target)
|
||||
# If it doesn't exist, create one in the remote_requested state
|
||||
# If it doesn't exist, create one in the unrequested state
|
||||
if follow is None:
|
||||
if create:
|
||||
return cls.objects.create(
|
||||
source=source,
|
||||
target=target,
|
||||
uri=data["id"],
|
||||
state=FollowStates.remote_requested,
|
||||
state=FollowStates.unrequested,
|
||||
)
|
||||
else:
|
||||
raise cls.DoesNotExist(
|
||||
|
@ -289,7 +345,6 @@ class Follow(StatorModel):
|
|||
"""
|
||||
Handles an incoming follow request
|
||||
"""
|
||||
from activities.models import TimelineEvent
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
|
@ -299,11 +354,9 @@ class Follow(StatorModel):
|
|||
"Identity not found for incoming Follow", extras={"data": data}
|
||||
)
|
||||
return
|
||||
|
||||
# Force it into remote_requested so we send an accept
|
||||
follow.transition_perform(FollowStates.remote_requested)
|
||||
# Add a timeline event
|
||||
TimelineEvent.add_follow(follow.target, follow.source)
|
||||
if follow.state == FollowStates.accepted:
|
||||
# Likely the source server missed the Accept, send it back again
|
||||
follow.transition_perform(FollowStates.accepting)
|
||||
|
||||
@classmethod
|
||||
def handle_accept_ap(cls, data):
|
||||
|
@ -324,11 +377,8 @@ class Follow(StatorModel):
|
|||
if data["actor"] != follow.target.actor_uri:
|
||||
raise ValueError("Accept actor does not match its Follow object", data)
|
||||
# If the follow was waiting to be accepted, transition it
|
||||
if follow and follow.state in [
|
||||
FollowStates.unrequested,
|
||||
FollowStates.local_requested,
|
||||
]:
|
||||
follow.transition_perform(FollowStates.accepted)
|
||||
if follow and follow.state == FollowStates.pending_approval:
|
||||
follow.transition_perform(FollowStates.accepting)
|
||||
|
||||
@classmethod
|
||||
def handle_reject_ap(cls, data):
|
||||
|
@ -348,8 +398,17 @@ class Follow(StatorModel):
|
|||
# Ensure the Accept actor is the Follow's target
|
||||
if data["actor"] != follow.target.actor_uri:
|
||||
raise ValueError("Reject actor does not match its Follow object", data)
|
||||
# Clear timeline if remote target remove local source from their previously accepted follows
|
||||
if follow.accepted:
|
||||
InboxMessage.create_internal(
|
||||
{
|
||||
"type": "ClearTimeline",
|
||||
"object": follow.target.pk,
|
||||
"actor": follow.source.pk,
|
||||
}
|
||||
)
|
||||
# Mark the follow rejected
|
||||
follow.transition_perform(FollowStates.rejected)
|
||||
follow.transition_perform(FollowStates.rejecting)
|
||||
|
||||
@classmethod
|
||||
def handle_undo_ap(cls, data):
|
||||
|
@ -369,4 +428,4 @@ class Follow(StatorModel):
|
|||
if data["actor"] != follow.source.actor_uri:
|
||||
raise ValueError("Accept actor does not match its Follow object", data)
|
||||
# Delete the follow
|
||||
follow.delete()
|
||||
follow.transition_perform(FollowStates.pending_removal)
|
||||
|
|
|
@ -991,7 +991,7 @@ class Identity(StatorModel):
|
|||
"avatar_static": self.local_icon_url().absolute,
|
||||
"header": header_image.absolute if header_image else missing,
|
||||
"header_static": header_image.absolute if header_image else missing,
|
||||
"locked": False,
|
||||
"locked": bool(self.manually_approves_followers),
|
||||
"fields": (
|
||||
[
|
||||
{
|
||||
|
|
|
@ -73,7 +73,7 @@ class IdentityService:
|
|||
return (
|
||||
Identity.objects.filter(
|
||||
outbound_follows__target=self.identity,
|
||||
inbound_follows__state__in=FollowStates.group_active(),
|
||||
outbound_follows__state=FollowStates.accepted,
|
||||
)
|
||||
.not_deleted()
|
||||
.distinct()
|
||||
|
@ -81,6 +81,28 @@ class IdentityService:
|
|||
.select_related("domain")
|
||||
)
|
||||
|
||||
def follow_requests(self) -> models.QuerySet[Identity]:
|
||||
return (
|
||||
Identity.objects.filter(
|
||||
outbound_follows__target=self.identity,
|
||||
outbound_follows__state=FollowStates.pending_approval,
|
||||
)
|
||||
.not_deleted()
|
||||
.distinct()
|
||||
.order_by("username")
|
||||
.select_related("domain")
|
||||
)
|
||||
|
||||
def accept_follow_request(self, source_identity):
|
||||
existing_follow = Follow.maybe_get(source_identity, self.identity)
|
||||
if existing_follow:
|
||||
existing_follow.transition_perform(FollowStates.accepting)
|
||||
|
||||
def reject_follow_request(self, source_identity):
|
||||
existing_follow = Follow.maybe_get(source_identity, self.identity)
|
||||
if existing_follow:
|
||||
existing_follow.transition_perform(FollowStates.rejecting)
|
||||
|
||||
def follow(self, target_identity: Identity, boosts=True) -> Follow:
|
||||
"""
|
||||
Follows a user (or does nothing if already followed).
|
||||
|
@ -114,6 +136,7 @@ class IdentityService:
|
|||
if target_identity == self.identity:
|
||||
raise ValueError("You cannot block yourself")
|
||||
self.unfollow(target_identity)
|
||||
self.reject_follow_request(target_identity)
|
||||
block = Block.create_local_block(self.identity, target_identity)
|
||||
InboxMessage.create_internal(
|
||||
{
|
||||
|
@ -221,8 +244,10 @@ class IdentityService:
|
|||
relationships = self.relationships(from_identity)
|
||||
return {
|
||||
"id": self.identity.pk,
|
||||
"following": relationships["outbound_follow"] is not None,
|
||||
"followed_by": relationships["inbound_follow"] is not None,
|
||||
"following": relationships["outbound_follow"] is not None
|
||||
and relationships["outbound_follow"].accepted,
|
||||
"followed_by": relationships["inbound_follow"] is not None
|
||||
and relationships["inbound_follow"].accepted,
|
||||
"showing_reblogs": (
|
||||
relationships["outbound_follow"]
|
||||
and relationships["outbound_follow"].boosts
|
||||
|
@ -233,7 +258,8 @@ class IdentityService:
|
|||
"blocked_by": relationships["inbound_block"] is not None,
|
||||
"muting": relationships["outbound_mute"] is not None,
|
||||
"muting_notifications": False,
|
||||
"requested": False,
|
||||
"requested": relationships["outbound_follow"] is not None
|
||||
and relationships["outbound_follow"].state == FollowStates.pending_approval,
|
||||
"domain_blocking": False,
|
||||
"endorsed": False,
|
||||
"note": (
|
||||
|
|
Loading…
Reference in a new issue