diff --git a/kepi/bowler_pub/create.py b/kepi/bowler_pub/create.py index f3e8afb..276b796 100644 --- a/kepi/bowler_pub/create.py +++ b/kepi/bowler_pub/create.py @@ -231,9 +231,9 @@ def on_note(fields, address): remote_url = fields['id'], account = poster, in_reply_to = in_reply_to, - content = fields['content'], + content_source = fields['content'], sensitive = is_sensitive, - spoiler_text = spoiler_text, + spoiler_source = spoiler_text, visibility = visibility, language = language, ) diff --git a/kepi/bowler_pub/serializers.py b/kepi/bowler_pub/serializers.py index e879abf..f4506de 100644 --- a/kepi/bowler_pub/serializers.py +++ b/kepi/bowler_pub/serializers.py @@ -33,7 +33,7 @@ class StatusObjectSerializer(serializers.ModelSerializer): 'id': status.url, 'url': status.url, 'type': 'Note', - 'summary': status.spoiler_text_as_html, + 'summary': status.spoiler_as_html, 'inReplyTo': status.in_reply_to, 'published': status.created_at, 'attributedTo': status.account.url, @@ -43,7 +43,7 @@ class StatusObjectSerializer(serializers.ModelSerializer): 'conversation': status.conversation, 'content': status.content_as_html, 'contentMap': { - status.language: status.content, + status.language: status.content_source, }, 'attachment': status.media_attachments, 'tag': status.tags, diff --git a/kepi/bowler_pub/tests/mastodon/test_announce.py b/kepi/bowler_pub/tests/mastodon/test_announce.py index 649af6c..40095e7 100644 --- a/kepi/bowler_pub/tests/mastodon/test_announce.py +++ b/kepi/bowler_pub/tests/mastodon/test_announce.py @@ -125,7 +125,7 @@ class Tests(TestCase): ) self.assertEqual( - original_status.content, + original_status.content_source, 'Hello world', msg = 'the status was reblogged at the end', ) diff --git a/kepi/bowler_pub/tests/mastodon/test_create.py b/kepi/bowler_pub/tests/mastodon/test_create.py index 6ac66f0..1788f2d 100644 --- a/kepi/bowler_pub/tests/mastodon/test_create.py +++ b/kepi/bowler_pub/tests/mastodon/test_create.py @@ -69,7 +69,7 @@ class Tests(Create_TestCase): import kepi.trilby_api.models as trilby_models result = trilby_models.Status.objects.filter( - content = content, + content_source = content, ) if result: diff --git a/kepi/bowler_pub/tests/test_collections.py b/kepi/bowler_pub/tests/test_collections.py index a131652..505213d 100644 --- a/kepi/bowler_pub/tests/test_collections.py +++ b/kepi/bowler_pub/tests/test_collections.py @@ -82,8 +82,22 @@ class Tests(TestCase): result = trilby_models.Status( account = self._alice, visibility = trilby_utils.VISIBILITY_PUBLIC, - content = "
Victoria Wood parodying Peter Skellern. I laughed so much at this, though you might have to know both singers' work in order to find it quite as funny.
- love song
- self-doubt
- refs to northern England
- preamble
- piano solo
- brass band
- choir backing
- love is cosy
- heavy rhotic vowels
{id}
') - expected = sorted(expected) - - details = sorted([x['content'] \ - for x in self.get( - path = path, - as_user = as_user, - )]) - - self.assertListEqual( - expected, - details, - msg = f"Visibility in '{situation}' mismatch: "+\ - f"expected {expected}, but got {details}.", - ) - - def test_public(self): - self._set_up() - - self._check_timelines( - situation = 'public', - path = '/api/v1/timelines/public', + data = None, as_user = None, + ): + + logger.info("Timeline contents for %s as %s...", + path, + as_user) + + found = self.get( + path = path, + data = data, + as_user = as_user, + ) + + logger.info(" -- retrieved") + + details = sorted([x['content'] for x in found]) + + logger.debug(" -- sorted as %s", + details) + + result = '' + for detail in details: + + if detail.startswith('') and detail.endswith('
'): + detail = detail[3:-4] + + result += detail + + logger.info(" -- contents are %s", + result) + + return result + +class TestPublicTimeline(TimelineTestCase): + + def test_as_anon(self): + + alice = create_local_person("alice") + + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=alice, content='B', visibility='U') + self.add_status(source=alice, content='C', visibility='X') + self.add_status(source=alice, content='D', visibility='D') + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + as_user = None, + ), + 'A', ) - def test_follower(self): - self._set_up() - self._george = create_local_person("george") + def test_as_user(self): + + alice = create_local_person("alice") + + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=alice, content='B', visibility='U') + self.add_status(source=alice, content='C', visibility='X') + self.add_status(source=alice, content='D', visibility='D') + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + as_user = alice, + ), + 'A', + ) + + def test_as_stranger(self): + + alice = create_local_person("alice") + henry = create_local_person("henry") + + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=alice, content='B', visibility='U') + self.add_status(source=alice, content='C', visibility='X') + self.add_status(source=alice, content='D', visibility='D') + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + as_user = henry, + ), + 'A', + ) + + @httpretty.activate() + def test_local_and_remote(self): + + alice = create_local_person("alice") + peter = create_remote_person( + remote_url = "https://example.com/users/peter", + name = "peter", + auto_fetch = True, + ) + + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=peter, content='B', visibility='A', + remote_url = 'https://example.com/users/peter/B') + self.add_status(source=alice, content='C', visibility='A') + self.add_status(source=peter, content='D', visibility='A', + remote_url = 'https://example.com/users/peter/D') + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + ), + 'ABCD', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'local': 'true'}, + ), + 'AC', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'local': 'false'}, + ), + 'ABCD', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'remote': 'true'}, + ), + 'BD', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'remote': 'false'}, + ), + 'ABCD', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'local': 'true', 'remote': 'true'}, + ), + '', + ) + + def test_only_media(self): + + # We don't support added media at present anyway, + # so turning this on will always get the empty set + + alice = create_local_person("alice") + + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=alice, content='B', visibility='A') + self.add_status(source=alice, content='C', visibility='A') + self.add_status(source=alice, content='D', visibility='A') + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'only_media': 'true'}, + ), + '', + ) + + def test_max_since_and_min(self): + + alice = create_local_person("alice") + + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=alice, content='B', visibility='A') + status_c = self.add_status(source=alice, content='C', visibility='A') + self.add_status(source=alice, content='D', visibility='A') + + c_id = str(status_c.id) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'since_id': status_c.id}, + ), + 'D', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'max_id': status_c.id}, + ), + 'ABC', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'min_id': status_c.id}, + ), + 'CD', + ) + + def test_limit(self): + + alice = create_local_person("alice") + + alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + for i in range(len(alphabet)): + self.add_status( + source=alice, + content=alphabet[i], + visibility='A', + ) + + for i in range(1, len(alphabet)): + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + data = {'limit': i}, + ), + alphabet[:i], + ) + + # the default is specified as 20 + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/public', + ), + alphabet[:20], + msg = 'default is 20', + ) + +class TestHomeTimeline(TimelineTestCase): + + def add_standard_statuses(self): + self.alice = create_local_person("alice") + self.bob = create_local_person("bob") + self.carol = create_local_person("carol") + + self.add_status(source=self.bob, content='A', visibility='A') + self.add_status(source=self.carol, content='B', visibility='A') + self.add_status(source=self.carol, content='C', visibility='A') + self.add_status(source=self.bob, content='D', visibility='A') + + Follow( + follower=self.alice, + following=self.bob, + offer=None).save() + + def follow_carol(self): + Follow( + follower=self.alice, + following=self.carol, + offer=None).save() + + def test_not_anon(self): + found = self.get( + path = '/api/v1/timelines/home', + as_user = None, + expect_result = 401, + ) + + def test_0_simple(self): + + self.add_standard_statuses() + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + as_user = self.alice, + ), + 'AD', + ) + + self.follow_carol() + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + as_user = self.alice, + ), + 'ABCD', + ) + + def test_max_since_and_min(self): + + self.add_standard_statuses() + + c_id = '3' # FIXME hack + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'since_id': c_id}, + as_user = self.alice, + ), + 'D', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'max_id': c_id}, + as_user = self.alice, + ), + 'A', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'min_id': c_id}, + as_user = self.alice, + ), + 'D', + ) + + self.follow_carol() + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'since_id': c_id}, + as_user = self.alice, + ), + 'D', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'max_id': c_id}, + as_user = self.alice, + ), + 'ABC', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'min_id': c_id}, + as_user = self.alice, + ), + 'CD', + ) + + def test_limit(self): + + self.alice = create_local_person("alice") + self.bob = create_local_person("bob") + self.carol = create_local_person("carol") + + Follow( + follower=self.alice, + following=self.bob, + offer=None).save() + + alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + for i in range(len(alphabet)): + self.add_status( + source=self.bob, + content=alphabet[i], + visibility='A', + ) + + self.add_status( + source=self.carol, + content=alphabet[i].lower(), + visibility='A', + ) + + for i in range(1, len(alphabet)): + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'limit': i}, + as_user = self.alice, + ), + alphabet[:i], + ) + + # the default is specified as 20 + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + as_user = self.alice, + ), + alphabet[:20], + msg = 'default is 20', + ) + + @httpretty.activate() + def test_local(self): + + self.add_standard_statuses() + + self.peter = create_remote_person( + remote_url = "https://example.com/users/peter", + name = "peter", + auto_fetch = True, + ) + + for letter in 'PQ': + self.add_status(source=self.peter, + remote_url = 'https://example.com/users/peter/{}'.format( + letter, + ), + content=letter, + visibility='A') + + Follow( + follower = self.alice, + following = self.peter, + offer = None, + ).save() + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + as_user = self.alice, + ), + 'ADPQ', + ) + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + data = {'local': 'true'}, + as_user = self.alice, + ), + 'AD', + ) + + def test_as_follower(self): + + alice = create_local_person("alice") + george = create_local_person("george") follow = Follow( - follower = self._george, - following = self._alice, + follower = george, + following = alice, offer = None, ) follow.save() - self._check_timelines( - situation = 'public', - path = '/api/v1/timelines/public', - as_user = self._george, + self.add_status(source=alice, content='A', visibility='A') + self.add_status(source=alice, content='B', visibility='U') + self.add_status(source=alice, content='C', visibility='X') + self.add_status(source=alice, content='D', visibility='D') + + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + as_user = george, + ), + 'A', ) - def test_stranger(self): - self._set_up() - self._henry = create_local_person("henry") + follow = Follow( + follower = alice, + following = george, + offer = None, + ) + follow.save() # they are now mutuals - self._check_timelines( - situation = 'public', - path = '/api/v1/timelines/public', - as_user = self._henry, - ) - - def test_home(self): - self._set_up() - - self._check_timelines( - situation = 'home', - path = '/api/v1/timelines/home', - as_user = self._alice, + self.assertEqual( + self.timeline_contents( + path = '/api/v1/timelines/home', + as_user = george, + ), + 'AC', ) +class TestTimelinesNotImplemented(TimelineTestCase): @skip("to be implemented later") def test_hashtag(self): raise NotImplementedError() diff --git a/kepi/trilby_api/views/statuses.py b/kepi/trilby_api/views/statuses.py index a11ba13..4b1e7ef 100644 --- a/kepi/trilby_api/views/statuses.py +++ b/kepi/trilby_api/views/statuses.py @@ -135,18 +135,18 @@ class Reblog(DoSomethingWithStatus): # https://github.com/tootsuite/mastodon/issues/13479 # For now, I'm assuming that you can. - content = 'RT {}'.format(the_status.content) + content_source = 'RT {}'.format(the_status.content_source) new_status = trilby_models.Status( # Fields which are different in a reblog: account = request.user.localperson, - content = content, + content_source = content_source, reblog_of = the_status, # Fields which are just copied in: sensitive = the_status.sensitive, - spoiler_text = the_status.spoiler_text, + spoiler_source = the_status.spoiler_source, visibility = the_status.visibility, language = the_status.language, in_reply_to = the_status.in_reply_to, @@ -397,9 +397,9 @@ class Statuses(generics.ListCreateAPIView, status = trilby_models.Status( account = request.user.localperson, - content = data.get('status', ''), + content_source = data.get('status', ''), sensitive = data.get('sensitive', False), - spoiler_text = data.get('spoiler_text', ''), + spoiler_source = data.get('spoiler_text', ''), visibility = data.get('visibility', 'public'), language = data.get('language', settings.KEPI['LANGUAGES'][0]), diff --git a/kepi/trilby_api/views/timelines.py b/kepi/trilby_api/views/timelines.py index 971156a..5c2c595 100644 --- a/kepi/trilby_api/views/timelines.py +++ b/kepi/trilby_api/views/timelines.py @@ -31,6 +31,7 @@ import json import re import random +DEFAULT_TIMELINE_SLICE_LENGTH = 20 class AbstractTimeline(generics.ListAPIView): @@ -39,29 +40,82 @@ class AbstractTimeline(generics.ListAPIView): IsAuthenticated, ] - def get_queryset(self, request): + def get_queryset(self): raise NotImplementedError("cannot query abstract timeline") - def get(self, request): - queryset = self.get_queryset(request) - serializer = self.serializer_class(queryset, - many = True, - context = { - 'request': request, - }) - return Response(serializer.data) + def filter_queryset(self, queryset, + min_id = None, + max_id = None, + since_id = None, + local = False, + remote = False, + limit = DEFAULT_TIMELINE_SLICE_LENGTH, + *args, **kwargs, + ): -PUBLIC_TIMELINE_SLICE_LENGTH = 20 + logger.debug("Timeline queryset: %s", queryset) + + if 'min_id' in self.request.query_params: + queryset = queryset.filter( + id__gte = int(self.request.query_params['min_id']), + ) + logger.debug(" -- after min_id: %s", queryset) + + if 'max_id' in self.request.query_params: + queryset = queryset.filter( + id__lte = int(self.request.query_params['max_id']), + ) + logger.debug(" -- after max_id: %s", queryset) + + if 'since_id' in self.request.query_params: + queryset = queryset.filter( + id__gt = int(self.request.query_params['since_id']), + ) + logger.debug(" -- after since_id: %s", queryset) + + if self.request.query_params.get('local', '')=='true': + queryset = queryset.filter( + remote_url__isnull = True, + ) + logger.debug(" -- after local: %s", queryset) + + if self.request.query_params.get('remote', '')=='true': + queryset = queryset.filter( + remote_url__isnull = False, + ) + logger.debug(" -- after remote: %s", queryset) + + if 'only_media' in self.request.query_params: + # We don't support media at present, so this will give us + # the empty set + queryset = queryset.none() + logger.debug(" -- after only_media: %s", queryset) + + # Slicing the queryset must be done last, + # since running operations on a sliced queryset + # causes evaluation. + limit = int(self.request.query_params.get('limit', + default = DEFAULT_TIMELINE_SLICE_LENGTH, + )) + + queryset = queryset[:limit] + + logger.debug(" -- after slice of %d: %s", + limit, + queryset, + ) + + return queryset class PublicTimeline(AbstractTimeline): permission_classes = () - def get_queryset(self, request): + def get_queryset(self): result = trilby_models.Status.objects.filter( visibility = trilby_utils.VISIBILITY_PUBLIC, - )[:PUBLIC_TIMELINE_SLICE_LENGTH] + ) return result @@ -71,9 +125,9 @@ class HomeTimeline(AbstractTimeline): IsAuthenticated, ] - def get_queryset(self, request): + def get_queryset(self): - result = request.user.localperson.inbox + result = self.request.user.localperson.inbox logger.debug("Home timeline is %s", result) @@ -82,8 +136,6 @@ class HomeTimeline(AbstractTimeline): ######################################## -######################################## - class UserFeed(View): permission_classes = ()