Fix tests and most of pagination

This commit is contained in:
Andrew Godwin 2023-01-08 23:06:09 -07:00
parent 91116fe6f8
commit fb881dd5de
7 changed files with 65 additions and 60 deletions

View file

@ -117,36 +117,17 @@ class PaginationResult:
class MastodonPaginator: class MastodonPaginator:
""" """
Paginates in the Mastodon style (max_id, min_id, etc). Paginates in the Mastodon style (max_id, min_id, etc).
Note that this basically _requires_ us to always do it on IDs, so we do.
""" """
def __init__( def __init__(
self, self,
anchor_model: type[models.Model],
sort_attribute: str = "created",
default_limit: int = 20, default_limit: int = 20,
max_limit: int = 40, max_limit: int = 40,
): ):
self.anchor_model = anchor_model
self.sort_attribute = sort_attribute
self.default_limit = default_limit self.default_limit = default_limit
self.max_limit = max_limit self.max_limit = max_limit
def get_anchor(self, anchor_id: str):
"""
Gets an anchor object by ID.
It's possible that the anchor object might be an interaction, in which
case we recurse down to its post.
"""
if anchor_id.startswith("interaction-"):
try:
return PostInteraction.objects.get(pk=anchor_id[12:])
except PostInteraction.DoesNotExist:
return None
try:
return self.anchor_model.objects.get(pk=anchor_id)
except self.anchor_model.DoesNotExist:
return None
def paginate( def paginate(
self, self,
queryset, queryset,
@ -156,32 +137,57 @@ class MastodonPaginator:
limit: int | None, limit: int | None,
) -> PaginationResult: ) -> PaginationResult:
if max_id: if max_id:
anchor = self.get_anchor(max_id) queryset = queryset.filter(id__lt=max_id)
if anchor is None:
return PaginationResult.empty()
queryset = queryset.filter(
**{self.sort_attribute + "__lt": getattr(anchor, self.sort_attribute)}
)
if since_id: if since_id:
anchor = self.get_anchor(since_id) queryset = queryset.filter(id__gt=since_id)
if anchor is None:
return PaginationResult.empty()
queryset = queryset.filter(
**{self.sort_attribute + "__gt": getattr(anchor, self.sort_attribute)}
)
if min_id: if min_id:
# Min ID requires items _immediately_ newer than specified, so we # Min ID requires items _immediately_ newer than specified, so we
# invert the ordering to accommodate # invert the ordering to accommodate
anchor = self.get_anchor(min_id) queryset = queryset.filter(id__gt=min_id).order_by("id")
if anchor is None:
return PaginationResult.empty()
queryset = queryset.filter(
**{self.sort_attribute + "__gt": getattr(anchor, self.sort_attribute)}
).order_by(self.sort_attribute)
else: else:
queryset = queryset.order_by("-" + self.sort_attribute) queryset = queryset.order_by("-id")
limit = min(limit or self.default_limit, self.max_limit)
return PaginationResult(
results=list(queryset[:limit]),
limit=limit,
)
def paginate_home(
self,
queryset,
min_id: str | None,
max_id: str | None,
since_id: str | None,
limit: int | None,
) -> PaginationResult:
"""
The home timeline requires special handling where we mix Posts and
PostInteractions together.
"""
if max_id:
queryset = queryset.filter(
models.Q(subject_post_id__lt=max_id)
| models.Q(subject_post_interaction_id__lt=max_id)
)
if since_id:
queryset = queryset.filter(
models.Q(subject_post_id__gt=max_id)
| models.Q(subject_post_interaction_id__gt=max_id)
)
if min_id:
# Min ID requires items _immediately_ newer than specified, so we
# invert the ordering to accommodate
queryset = queryset.filter(
models.Q(subject_post_id__gt=max_id)
| models.Q(subject_post_interaction_id__gt=max_id)
).order_by("id")
else:
queryset = queryset.order_by("-id")
limit = min(limit or self.default_limit, self.max_limit) limit = min(limit or self.default_limit, self.max_limit)
return PaginationResult( return PaginationResult(

View file

@ -3,7 +3,6 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Field from ninja import Field
from activities.models import Post
from activities.services import SearchService from activities.services import SearchService
from api import schemas from api import schemas
from api.decorators import identity_required from api.decorators import identity_required
@ -151,7 +150,7 @@ def account_statuses(
if tagged: if tagged:
queryset = queryset.tagged_with(tagged) queryset = queryset.tagged_with(tagged)
# Get user posts with pagination # Get user posts with pagination
paginator = MastodonPaginator(Post, sort_attribute="published") paginator = MastodonPaginator()
pager = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
@ -219,7 +218,7 @@ def account_following(
service = IdentityService(identity) service = IdentityService(identity)
paginator = MastodonPaginator(Identity, max_limit=80, sort_attribute="username") paginator = MastodonPaginator(max_limit=80)
pager = paginator.paginate( pager = paginator.paginate(
service.following(), service.following(),
min_id=min_id, min_id=min_id,

View file

@ -35,7 +35,7 @@ def notifications(
queryset = TimelineService(request.identity).notifications( queryset = TimelineService(request.identity).notifications(
[base_types[r] for r in requested_types if r in base_types] [base_types[r] for r in requested_types if r in base_types]
) )
paginator = MastodonPaginator(TimelineEvent) paginator = MastodonPaginator()
pager = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,

View file

@ -16,7 +16,6 @@ from activities.services import PostService
from api import schemas from api import schemas
from api.views.base import api_router from api.views.base import api_router
from core.models import Config from core.models import Config
from users.models import Identity
from ..decorators import identity_required from ..decorators import identity_required
from ..pagination import MastodonPaginator from ..pagination import MastodonPaginator
@ -142,20 +141,18 @@ def favourited_by(
# a concept of "private status" yet. # a concept of "private status" yet.
post = get_object_or_404(Post, pk=id) post = get_object_or_404(Post, pk=id)
paginator = MastodonPaginator(Identity, sort_attribute="published") paginator = MastodonPaginator()
pager = paginator.paginate( pager = paginator.paginate(
post.interactions.filter( post.interactions.filter(
type=PostInteraction.Types.like, type=PostInteraction.Types.like,
state__in=PostInteractionStates.group_active(), state__in=PostInteractionStates.group_active(),
) ).select_related("identity"),
.select_related("identity")
.order_by("published"),
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
since_id=since_id, since_id=since_id,
limit=limit, limit=limit,
) )
pager.jsonify_identities() pager.jsonify_results(lambda r: r.identity.to_mastodon_json(include_counts=False))
if pager.results: if pager.results:
response.headers["Link"] = pager.link_header( response.headers["Link"] = pager.link_header(

View file

@ -1,6 +1,5 @@
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from activities.models import Post
from activities.services import TimelineService from activities.services import TimelineService
from api import schemas from api import schemas
from api.decorators import identity_required from api.decorators import identity_required
@ -20,9 +19,9 @@ def home(
limit: int = 20, limit: int = 20,
): ):
# Grab a paginated result set of instances # Grab a paginated result set of instances
paginator = MastodonPaginator(Post, sort_attribute="published") paginator = MastodonPaginator()
queryset = TimelineService(request.identity).home() queryset = TimelineService(request.identity).home()
pager = paginator.paginate( pager = paginator.paginate_home(
queryset, queryset,
min_id=min_id, min_id=min_id,
max_id=max_id, max_id=max_id,
@ -61,7 +60,7 @@ def public(
if only_media: if only_media:
queryset = queryset.filter(attachments__id__isnull=True) queryset = queryset.filter(attachments__id__isnull=True)
# Grab a paginated result set of instances # Grab a paginated result set of instances
paginator = MastodonPaginator(Post, sort_attribute="published") paginator = MastodonPaginator()
pager = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,
@ -101,7 +100,7 @@ def hashtag(
if only_media: if only_media:
queryset = queryset.filter(attachments__id__isnull=True) queryset = queryset.filter(attachments__id__isnull=True)
# Grab a paginated result set of instances # Grab a paginated result set of instances
paginator = MastodonPaginator(Post, sort_attribute="published") paginator = MastodonPaginator()
pager = paginator.paginate( pager = paginator.paginate(
queryset, queryset,
min_id=min_id, min_id=min_id,

View file

@ -38,15 +38,18 @@ def test_webfinger_system_actor(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_actor(client, identity): def test_delete_unknown_actor(client, identity):
"""
Tests that unknown actor delete messages are dropped
"""
data = { data = {
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://mastodon.social/users/fakec8b6984105c8f15070a2", "actor": "https://mastodon.test/users/fakec8b6984105c8f15070a2",
"id": "https://mastodon.social/users/fakec8b6984105c8f15070a2#delete", "id": "https://mastodon.test/users/fakec8b6984105c8f15070a2#delete",
"object": "https://mastodon.social/users/fakec8b6984105c8f15070a2", "object": "https://mastodon.test/users/fakec8b6984105c8f15070a2",
"signature": { "signature": {
"created": "2022-12-06T03:54:28Z", "created": "2022-12-06T03:54:28Z",
"creator": "https://mastodon.social/users/fakec8b6984105c8f15070a2#main-key", "creator": "https://mastodon.test/users/fakec8b6984105c8f15070a2#main-key",
"signatureValue": "This value doesn't matter", "signatureValue": "This value doesn't matter",
"type": "RsaSignature2017", "type": "RsaSignature2017",
}, },
@ -56,4 +59,5 @@ def test_delete_actor(client, identity):
resp = client.post( resp = client.post(
identity.inbox_uri, data=data, content_type="application/activity+json" identity.inbox_uri, data=data, content_type="application/activity+json"
) )
print(resp.content)
assert resp.status_code == 202 assert resp.status_code == 202

View file

@ -132,7 +132,7 @@ class Inbox(View):
if ( if (
document["type"] == "Delete" document["type"] == "Delete"
and document["actor"] == document["object"] and document["actor"] == document["object"]
and not identity.pk and identity._state.adding
): ):
# We don't have an Identity record for the user. No-op # We don't have an Identity record for the user. No-op
exceptions.capture_message( exceptions.capture_message(