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

https://youtu.be/782hqdmnq7g

", - ) + content_source = """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 + +https://youtu.be/782hqdmnq7g""", + ) result.save() return result diff --git a/kepi/sombrero_sendpub/receivers.py b/kepi/sombrero_sendpub/receivers.py index ab43947..29f8570 100644 --- a/kepi/sombrero_sendpub/receivers.py +++ b/kepi/sombrero_sendpub/receivers.py @@ -59,7 +59,7 @@ def on_posted(sender, **kwargs): "object": { "type": "Note", "id": sender.url, - "content": sender.content, + "content": sender.content_as_html, } }, sender = sender.account, diff --git a/kepi/trilby_api/migrations/0029_auto_20210216_1914.py b/kepi/trilby_api/migrations/0029_auto_20210216_1914.py new file mode 100644 index 0000000..65dcf9c --- /dev/null +++ b/kepi/trilby_api/migrations/0029_auto_20210216_1914.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.9 on 2021-02-16 19:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('trilby_api', '0028_mention'), + ] + + operations = [ + migrations.RenameField( + model_name='status', + old_name='spoiler_text', + new_name='spoiler_source', + ), + migrations.RemoveField( + model_name='status', + name='content', + ), + migrations.AddField( + model_name='status', + name='content_as_html_denormed', + field=models.TextField(default=None, editable=False, help_text='HTML rendering of content_source. Do not edit!', null=True), + ), + migrations.AddField( + model_name='status', + name='content_source', + field=models.TextField(default='', help_text='Text of the status, as entered'), + preserve_default=False, + ), + migrations.AddField( + model_name='status', + name='spoiler_as_html_denormed', + field=models.CharField(default=None, editable=False, max_length=255, null=True), + ), + ] diff --git a/kepi/trilby_api/models/person.py b/kepi/trilby_api/models/person.py index 26159e1..62fc20e 100644 --- a/kepi/trilby_api/models/person.py +++ b/kepi/trilby_api/models/person.py @@ -9,6 +9,7 @@ logger = logging.getLogger(name='kepi') from polymorphic.models import PolymorphicModel from django.db import models +from django.db.models import Q from django.db.models.constraints import UniqueConstraint from django.contrib.auth.models import AbstractUser from django.conf import settings @@ -591,41 +592,52 @@ class LocalPerson(Person): import kepi.trilby_api.models as trilby_models - # tags aren't implemented; FIXME - everything_youre_tagged_in = trilby_models.Status.objects.none() + # "Everything you're tagged in": + # tags aren't implemented; FIXME - logger.debug("%s.inbox: tagged in: %s", - self, everything_youre_tagged_in) + all_your_posts = Q(account = self) - all_your_posts = trilby_models.Status.objects.filter( - account = self, - ) + # note: querysets don't get evaluated unless used, + # so the debug logging doesn't cause a db hit + # unless it's actually turned on. - logger.debug("%s.inbox: all your posts: %s", - self, all_your_posts) + logger.debug("%s.inbox: your own posts: %s", + self, + trilby_models.Status.objects.filter( + all_your_posts + )) - all_your_friends_public_posts = trilby_models.Status.objects.filter( + all_your_friends_public_posts = Q( visibility = trilby_utils.VISIBILITY_PUBLIC, - account__rel_following__following = self, + account__rel_followers__follower = self, ) - logger.debug("%s.inbox: all friends' public: %s", - self, all_your_friends_public_posts) + logger.debug("%s.inbox: your friends' public posts: %s", + self, + trilby_models.Status.objects.filter( + all_your_friends_public_posts + )) - all_your_mutuals_private_posts = trilby_models.Status.objects.filter( + all_your_mutuals_private_posts = Q( visibility = trilby_utils.VISIBILITY_PRIVATE, account__rel_following__following = self, account__rel_followers__follower = self, ) - logger.debug("%s.inbox: all mutuals' private: %s", - self, all_your_mutuals_private_posts) + logger.debug("%s.inbox: your mutuals' private posts: %s", + self, + trilby_models.Status.objects.filter( + all_your_mutuals_private_posts + )) - result = everything_youre_tagged_in.union( - all_your_posts, - all_your_friends_public_posts, - all_your_mutuals_private_posts, - ) + result = trilby_models.Status.objects.filter( + all_your_posts | \ + all_your_friends_public_posts | \ + all_your_mutuals_private_posts + ) + + logger.info("%s.inbox: contains %s", + self, result) return result diff --git a/kepi/trilby_api/models/status.py b/kepi/trilby_api/models/status.py index e1d9e9b..902bd30 100644 --- a/kepi/trilby_api/models/status.py +++ b/kepi/trilby_api/models/status.py @@ -59,7 +59,15 @@ class Status(PolymorphicModel): blank = True, ) - content = models.TextField( + content_source = models.TextField( + help_text = 'Text of the status, as entered', + ) + + content_as_html_denormed = models.TextField( + help_text = 'HTML rendering of content_source. Do not edit!', + editable = False, + null = True, + default = None, ) created_at = models.DateTimeField( @@ -72,13 +80,20 @@ class Status(PolymorphicModel): default = False, ) - spoiler_text = models.CharField( + spoiler_source = models.CharField( max_length = 255, null = True, blank = True, default = '', ) + spoiler_as_html_denormed = models.CharField( + max_length = 255, + null = True, + editable = False, + default = None, + ) + visibility = models.CharField( max_length = 1, default = trilby_utils.VISIBILITY_PUBLIC, @@ -107,6 +122,44 @@ class Status(PolymorphicModel): default = None, ) + @property + def content_as_html(self): + """ + Returns an HTML rendition of content_source. + The return value will be cached. + Saving the record will clear this cache. + """ + + if self.content_as_html_denormed is not None: + return self.content_as_html_denormed + + if self.content_source is None: + result = '

' + else: + result = markdown.markdown(self.content_source) + + self.content_as_html_denormed = result + return result + + @property + def spoiler_as_html(self): + """ + Returns an HTML rendition of spoiler_source. + The return value will be cached. + Saving the record will clear this cache. + """ + + if self.spoiler_as_html_denormed is not None: + return self.spoiler_as_html_denormed + + if self.spoiler_source is None: + result = '

' + else: + result = markdown.markdown(self.spoiler_source) + + self.spoiler_as_html_denormed = result + return result + @property def emojis(self): return [] # TODO @@ -268,6 +321,19 @@ class Status(PolymorphicModel): if self.in_reply_to == self: raise ValueError("Status can't be a reply to itself") + if not newly_made: + old = self.__class__.objects.get(pk=self.pk) + + if self.content_source != old.content_source: + logger.debug("%s: content changed; flushing HTML cache", + self) + self.content_as_html_denormed = None + + if self.spoiler_source != old.spoiler_source: + logger.debug("%s: spoiler changed; flushing HTML cache", + self) + self.spoiler_as_html_denormed = None + super().save(*args, **kwargs) if send_signal and newly_made: @@ -278,9 +344,9 @@ class Status(PolymorphicModel): trilby_signals.reblogged.send(sender=self) def __str__(self): - return '[Status %s: %s]' % ( + return '%s: %s' % ( self.id, - self.content, + self.content_source, ) @classmethod @@ -350,20 +416,8 @@ class Status(PolymorphicModel): # HTML and one is plain text. But the docs don't # seem to be forthcoming on this point, so we'll # just have to wait until we find out. - return self.content + return self.content_source @property def is_local(self): return self.remote_url is None - - @property - def content_as_html(self): - if not self.content: - return '

' - return markdown.markdown(self.content) - - @property - def spoiler_text_as_html(self): - if not self.spoiler_text: - return '

' - return markdown.markdown(self.spoiler_text) diff --git a/kepi/trilby_api/serializers.py b/kepi/trilby_api/serializers.py index 123d359..0834a76 100644 --- a/kepi/trilby_api/serializers.py +++ b/kepi/trilby_api/serializers.py @@ -208,26 +208,24 @@ class StatusSerializer(serializers.ModelSerializer): # "content" is read-only for HTML; # "status" is write-only for text (or Markdown) - content = serializers.SerializerMethodField( + content = serializers.CharField( + source='content_as_html', read_only = True) status = serializers.CharField( - source='source_text', + source='content_source', write_only = True) - def get_content(self, status): - result = markdown.markdown(status.content) - return result - created_at = serializers.DateTimeField( required = False, read_only = True) - # TODO Media + # TODO Media sensitive = serializers.BooleanField( required = False) spoiler_text = serializers.CharField( + source='spoiler_source', allow_blank = True, required = False) diff --git a/kepi/trilby_api/tests/__init__.py b/kepi/trilby_api/tests/__init__.py index 8ab0908..ecdbf5c 100644 --- a/kepi/trilby_api/tests/__init__.py +++ b/kepi/trilby_api/tests/__init__.py @@ -1,9 +1,18 @@ +# trilby_api/tests/__init__.py +# +# Part of kepi. +# Copyright (c) 2018-2021 Marnanel Thurman. +# Licensed under the GNU Public License v2. + from django.test import TestCase, Client from rest_framework.test import force_authenticate, APIClient from kepi.trilby_api.models import * from django.conf import settings import json +import logging +logger = logging.getLogger(name='kepi') + ACCOUNT_EXPECTED = { 'username': 'alice', 'acct': 'alice', @@ -74,11 +83,11 @@ class TrilbyTestCase(TestCase): return result def request(self, verb, path, - data={}, + data = None, as_user=None, expect_result=200, parse_result=True, - *args, **kwargs, + **extra, ): c = APIClient() @@ -92,8 +101,7 @@ class TrilbyTestCase(TestCase): path=path, data=data, format='json', - *args, - **kwargs, + extra=extra, ) if expect_result is not None: @@ -155,7 +163,7 @@ def create_local_status( result = Status( remote_url = None, account = posted_by, - content = content, + content_source = content, **kwargs, ) diff --git a/kepi/trilby_api/tests/test_reblog.py b/kepi/trilby_api/tests/test_reblog.py index 691a183..0f71b19 100644 --- a/kepi/trilby_api/tests/test_reblog.py +++ b/kepi/trilby_api/tests/test_reblog.py @@ -23,7 +23,7 @@ class TestReblog(TestCase): ) reblog = create_local_status( - content = original.content, + content = original.content_source, posted_by = bob, reblog_of = original, ) @@ -48,7 +48,7 @@ class TestReblog(TestCase): ) reblog = create_local_status( - content = original.content, + content = original.content_source, posted_by = bob, reblog_of = original, ) @@ -87,7 +87,7 @@ class TestReblog(TestCase): for i in range(1, 20): reblog = create_local_status( - content = original.content, + content = original.content_source, posted_by = bob, reblog_of = original, ) diff --git a/kepi/trilby_api/tests/test_status.py b/kepi/trilby_api/tests/test_status.py index 0b53b57..a489e26 100644 --- a/kepi/trilby_api/tests/test_status.py +++ b/kepi/trilby_api/tests/test_status.py @@ -685,7 +685,7 @@ class TestGetStatus(TrilbyTestCase): remote_url = "https://example.org/people/bob/status/100", account = self._bob, in_reply_to = self._alice_status, - content = "Buttercups our gold.", + content_source = "Buttercups our gold.", ) self._bob_status.save() diff --git a/kepi/trilby_api/tests/test_timelines.py b/kepi/trilby_api/tests/test_timelines.py index a680293..3b03da5 100644 --- a/kepi/trilby_api/tests/test_timelines.py +++ b/kepi/trilby_api/tests/test_timelines.py @@ -1,7 +1,7 @@ # test_timelines.py # # Part of kepi. -# Copyright (c) 2018-2020 Marnanel Thurman. +# Copyright (c) 2018-2021 Marnanel Thurman. # Licensed under the GNU Public License v2. import logging @@ -11,123 +11,515 @@ from rest_framework.test import APIClient, force_authenticate from kepi.trilby_api.views import * from kepi.trilby_api.tests import * from kepi.trilby_api.models import * +from kepi.bowler_pub.tests import create_remote_person from django.conf import settings from unittest import skip +import httpretty -# Tests for timelines. API docs are here: -# https://docs.joinmastodon.org/methods/statuses/ +""" +Tests for timelines. API docs are here: +https://docs.joinmastodon.org/methods/timelines/ +""" -TIMELINE_DATA = [ - # Visibility is: - # A=public: visible to anyone, and in public timelines - # U=unlisted: visible to anyone, but not in public timelines - # X=private: visible to followers and anyone tagged - # D=direct: visible only to those who are tagged +class TimelineTestCase(TrilbyTestCase): - # We haven't yet implemented: - # - (user) tags - # - hashtags - # - user lists - # - following users but hiding reblogs - # and when we do, these tests will need updating. - # - # All statuses are posted by alice. - # - # id visibility visible in - ( 'A', 'A', - ['public', 'follower', 'stranger', 'home', ], ), - ( 'B', 'U', - ['follower', 'stranger', 'home', ], ), - ( 'C', 'X', - ['follower', 'home',], ), - ( 'D', 'D', - ['home', ], ), + def add_status(self, source, visibility, content, + remote_url = None): + status = Status( + account = source, + content_source = content, + visibility = visibility, + remote_url = remote_url, + ) + status.save() - ] + logger.info("Created status: %s", status) -class TestTimelines(TrilbyTestCase): + return status - def _set_up(self): - - self._alice = create_local_person("alice") - - for (id, visibility, visible_in) in TIMELINE_DATA: - status = Status( - account = self._alice, - content = id, - visibility = visibility, - ) - status.save() - - def _check_timelines(self, - situation, + def timeline_contents(self, path, - as_user): - - expected = [] - for (id, visibility, visible_in) in TIMELINE_DATA: - if situation in visible_in: - expected.append(f'

{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 = ()