Porównaj commity

...

38 Commity

Autor SHA1 Wiadomość Data
Marnanel Thurman c8a5b5308b TrilbyTestCase descends from KepiTestCase 2023-09-23 14:32:59 +01:00
Marnanel Thurman 94309cc77d KepiTestCase
The only difference from django.test.TestCase is that KepiTestCase
redirects logging to stdout in setUp, and undoes it again in
tearDown. This is because Django's "./manage.py test" suppresses
stderr, so you can't read the debug logs otherwise.

Put into use in a couple of files where it was needed immediately.
Will add more later.
2023-09-22 16:36:51 +01:00
Marnanel Thurman b00f844d84 Follow -> FollowUser; Unfollow -> UnfollowUser
Since k.t.models also has a Follow class, it was getting confusing.

urls also doesn't "import *" the views classes any more, for clarity.

There were some classes duplicated between persons.py and statuses.py;
they have been confined to persons.py.

__init__.py has content.
2023-09-22 16:36:51 +01:00
Marnanel Thurman 8a0deb6c93 AppConfigs set default_auto_field
Django used to default this, but it generates a warning now.
2023-09-22 16:36:47 +01:00
Marnanel Thurman 4d63fd669d Remove "providing_args" from signals
This will probably break them, but we don't need them at present.
2023-09-22 16:36:22 +01:00
Thomas Thurman 07875e03ab config error, marnanel.org specific 2023-09-22 16:36:22 +01:00
Marnanel Thurman 77b45772bb Update to new version of celery (so djcelery is removed).
This seems to involve unsetting TEST_RUNNER. I'm not certain
this is correct; I'm asking on IRC.
2021-05-31 19:13:07 +01:00
Marnanel Thurman 7233d203bd trilby's AppConfig.name set to the fully-qualified path 2021-05-31 16:13:34 +01:00
Marnanel Thurman 02688a8422 obsolete url() -> path() 2021-02-19 19:31:50 +00:00
Marnanel Thurman ebc4052495 Rewrite oauth2 entries in urls.py. They were wrong, and this should
have fixed issue 70, but it doesn't.
2021-02-18 20:30:02 +00:00
Marnanel Thurman 6174859722 Merge branch 'issue-68' into 'main'
Issue 68

See merge request marnanel/chapeau!2
2021-02-18 20:26:57 +00:00
Marnanel Thurman 71c6d26ad8 Param values are strings, not bools.
All tests now pass!
2021-02-18 19:24:48 +00:00
Marnanel Thurman 574176b42b "only_media" stub 2021-02-18 18:47:45 +00:00
Marnanel Thurman a5848e45e2 test_as_follower moved to home timeline test, rather than public timeline test.
Test of "since" param, which doesn't exist, replaced with the correct "since_id".

Removed a lot of debug code that shouldn't have been checked in.

Fixed some comments.
2021-02-18 18:37:43 +00:00
Marnanel Thurman 46b3dcfbf7 __ge and __le filters replaced with the correct __gte and __lte 2021-02-18 18:37:11 +00:00
Marnanel Thurman 31dede8c2c Better logging for inbox calculation 2021-02-18 18:36:37 +00:00
Marnanel Thurman 3ad00cfae0 tests for remote and local params on timelines fixed to produce actual remote statuses! 2021-02-17 22:44:21 +00:00
Marnanel Thurman 37d53b2e4e Status.content and Status.spoiler_text are now Status.content_source and Status.spoiler_source.
HTML renderings of each one are cached. You can access them at Status.content_as_html
and Status.spoiler_as_html.
2021-02-16 22:58:42 +00:00
Marnanel Thurman 061ce40101 Remote/local test for Status objects is remote_url__isnull 2021-02-15 18:24:48 +00:00
Marnanel Thurman 5bf75d6c98 Fixed expected results which were wrong 2021-02-15 18:24:34 +00:00
Marnanel Thurman dbdad70376 LocalPerson.inbox uses Q objects rather than union(), because
union precludes filtering later.

See:
https://stackoverflow.com/questions/49260393/django-filter-a-queryset-made-of-unions-not-working
2021-02-15 18:21:57 +00:00
Marnanel Thurman e77ce337c4 Heroic attempt to work with django_rest_framework instead of fighting against it.
Specifically, filter_queryset() does the filtering, and we don't attempt
to provide our own get() on a ListAPIView.
2021-02-14 22:03:37 +00:00
Marnanel Thurman 8eb2b2468d Timelines tests pass GET params in via "data" and not literally in the path.
We start testing limits with limit=1 rather than limit=0, because it was
confusing the defaults mechanism further up. I'm not sure limit=0 is useful
anywhere at all.

And some minor fixes.
2021-02-14 22:01:43 +00:00
Marnanel Thurman d4af44913b TrilbyTestCase uses the "data" and "extra" params the same way as Django's test client 2021-02-14 22:00:18 +00:00
Marnanel Thurman a9d03dd280 Shorten Status.__str__ because it gets overwhelming in the logs 2021-02-14 21:59:47 +00:00
Marnanel Thurman 6ae898af03 Inbox lookup for LocalPerson used the "follow" relationship backwards; now fixed. 2021-02-14 21:57:32 +00:00
Marnanel Thurman 3a3ce2fae2 Home timeline tests added 2021-02-09 18:06:04 +00:00
Marnanel Thurman 17519f62cb split public and home timeline tests to separate classes 2021-02-09 17:42:10 +00:00
Marnanel Thurman f40a6d862d Many new timeline tests, per spec 2021-02-09 17:39:39 +00:00
Marnanel Thurman 312b3760fc Started to put test_timelines in order. Each test sets up the statuses as it needs. 2021-02-07 20:30:03 +00:00
Marnanel Thurman 63955031e5 static/ added to .gitignore 2021-02-02 20:10:36 +00:00
Marnanel Thurman 30f445aa3b trilby_api/views.py split out into several modules in trilby_api/views/.
Tests all pass.

Verify_Credentials renamed to VerifyCredentials for consistency.

This is to make things easier when fixing issue #68.
2021-02-02 20:08:32 +00:00
Marnanel Thurman 60b70f9cee "/home" served, but with nothing very interesting on it 2021-01-10 21:46:38 +00:00
Marnanel Thurman 98a5bde0b2 Display username in the navbar, and logout link, if you're logged in 2021-01-05 21:59:18 +00:00
Marnanel Thurman 4ef96ea6db LOGOUT_REDIRECT_URL 2021-01-05 21:56:26 +00:00
Marnanel Thurman 7e763ed4bf LOGIN_REDIRECT_URL set 2021-01-05 21:47:37 +00:00
Marnanel Thurman 99981e3baf login stuff moved to /accounts/login (from /login) so we can use auth's urls.py in a separate subdir 2021-01-05 21:45:34 +00:00
Marnanel Thurman 2198350394 INSTANCE_BLURB. Closes issue #66. 2021-01-04 02:57:17 +00:00
42 zmienionych plików z 1920 dodań i 1134 usunięć

1
.gitignore vendored
Wyświetl plik

@ -7,6 +7,7 @@ __pycache__
*.sqlite3
build/
dist/
static
examples
kepi/static
kepi/kepi/local_config.py

Wyświetl plik

@ -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,
)

Wyświetl plik

@ -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,

Wyświetl plik

@ -1,5 +1,3 @@
from django.dispatch import Signal
received = Signal(
providing_args=[
])
received = Signal()

Wyświetl plik

@ -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',
)

Wyświetl plik

@ -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:

Wyświetl plik

@ -82,8 +82,22 @@ class Tests(TestCase):
result = trilby_models.Status(
account = self._alice,
visibility = trilby_utils.VISIBILITY_PUBLIC,
content = "<p>Victoria Wood parodying Peter Skellern. I laughed so much at this, though you might have to know both singers&apos; work in order to find it quite as funny.</p><p>- love song<br />- self-doubt<br />- refs to northern England<br />- preamble<br />- piano solo<br />- brass band<br />- choir backing<br />- love is cosy<br />- heavy rhotic vowels</p><p><a href=\"https://youtu.be/782hqdmnq7g\" rel=\"nofollow noopener\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">youtu.be/782hqdmnq7g</span><span class=\"invisible\"></span></a></p>",
)
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

Wyświetl plik

@ -19,4 +19,5 @@ urlpatterns = [
path('users/<str:username>/following', bowler_pub_views.FollowingView.as_view()),
path('users/<str:username>/featured', bowler_pub_views.FeaturedView.as_view()),
path('sharedInbox', bowler_pub_views.InboxView.as_view()),
path('inbox', bowler_pub_views.InboxView.as_view()), # config error, marnanel.org specific
]

Wyświetl plik

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app', )

Wyświetl plik

@ -0,0 +1,13 @@
import os
from celery import Celery
app = Celery('kepi')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
print(f'Request: {self.request!r}')

Wyświetl plik

@ -1,7 +1,5 @@
import os
import djcelery
import logging
djcelery.setup_loader()
logger = logging.Logger(name='kepi')
@ -59,6 +57,7 @@ KEPI = {
'INSTANCE_NAME': 'kepi server',
'INSTANCE_DESCRIPTION': 'this is a test server',
'INSTANCE_BLURB': 'Welcome to this kepi instance. It\'s not properly set up yet.',
'CONTACT_ACCOUNT': 'marnanel',
'CONTACT_EMAIL': 'marnanel@example.com',
'LANGUAGES': [LANGUAGE_CODE],
@ -83,7 +82,6 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'djcelery',
'django_celery_results',
'rest_framework',
@ -141,8 +139,6 @@ LOGGING = {
},
}
TEST_RUNNER = 'djcelery.contrib.test_runner.CeleryTestSuiteRunner'
TIME_ZONE = 'UTC'
USE_I18N = True
@ -210,6 +206,8 @@ REST_FRAMEWORK = {
}
AUTH_USER_MODEL = 'trilby_api.TrilbyUser'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
try:
from .local_config import *

Wyświetl plik

@ -0,0 +1,21 @@
import logging
import sys
import django
logger = logging.getLogger('kepi')
class KepiTestCase(django.test.TestCase):
"""
A test case.
It turns on logging to stdout.
"""
def setUp(self):
super().setUp()
self._logging_stream_handler = logging.StreamHandler(sys.stdout)
logger.addHandler(self._logging_stream_handler)
def tearDown(self):
super().tearDown()
logger.removeHandler(self._logging_stream_handler)

Wyświetl plik

@ -16,20 +16,19 @@ from . import settings
fix_oauth2_redirects()
oauth2_endpoint_views = [
path('authorize', oauth2_views.AuthorizationView.as_view(), name="authorize"),
path('token', oauth2_views.TokenView.as_view(), name="token"),
path('revoke-token', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"),
path(r'authorize/', oauth2_views.AuthorizationView.as_view(), name="authorize"),
path(r'token/', oauth2_views.TokenView.as_view(), name="token"),
path(r'revoke-token/', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"),
]
oauth2_patterns = (oauth2_endpoint_views, "oauth2_provider")
##################################################
urlpatterns = [
path(r'admin/', admin.site.urls),
# auth
path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
path('oauth/', include((oauth2_endpoint_views, 'oauth2_provider'), namespace="oauth2_provider")),
path(r'accounts/', include('django.contrib.auth.urls')),
path(r'oauth2/', include(oauth2_patterns)),
# kepi's own stuff
path(r'', include(kepi.tophat_ui.urls)),

Wyświetl plik

@ -0,0 +1,5 @@
import django.apps
class SombreroApiConfig(django.apps.AppConfig):
name = 'kepi.sombrero_sendpub'
default_auto_field = 'django.db.models.AutoField'

Wyświetl plik

@ -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,

Wyświetl plik

@ -8,12 +8,12 @@ import logging
logger = logging.getLogger(name="kepi")
from unittest import skip
from django.test import TestCase
from django.conf import settings
from kepi.sombrero_sendpub.fetch import fetch
from kepi.trilby_api.models import RemotePerson, Person, Status
from kepi.trilby_api.tests import create_local_person
from kepi.sombrero_sendpub.collections import Collection
from kepi.kepi.testing import KepiTestCase
from . import suppress_thread_exceptions
import httpretty
import requests
@ -166,7 +166,7 @@ EXAMPLE_COMPLEX_COLLECTION_PAGE_2 = """{
EXAMPLE_COMPLEX_COLLECTION_URL,
)
class TestFetchRemoteUser(TestCase):
class TestFetchRemoteUser(KepiTestCase):
@httpretty.activate
def test_fetch(self):
@ -527,9 +527,10 @@ class TestFetchRemoteUser(TestCase):
len(EXAMPLE_COMPLEX_COLLECTION_MEMBERS),
msg="Collection has a length")
class TestFetchLocalUser(TestCase):
class TestFetchLocalUser(KepiTestCase):
def setUp(self):
super().setUp()
self._alice = create_local_person(
name = 'alice',
)
@ -588,5 +589,5 @@ class TestFetchLocalUser(TestCase):
None,
)
class TestFetchStatus(TestCase):
class TestFetchStatus(KepiTestCase):
pass

Wyświetl plik

@ -0,0 +1,3 @@
{% extends 'base.html' %}
{% block content %}home{% endblock %}

Wyświetl plik

@ -1,9 +1,18 @@
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
<a class="nav-link" href="/home">Home</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/home">&#x1f452;{{ user.username }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login/">Login</a>
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a>
</li>
{% endif %}
</ul>

Wyświetl plik

@ -2,7 +2,6 @@
{% block content %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}

Wyświetl plik

@ -1,3 +1,3 @@
{% extends 'base.html' %}
{% block content %}Welcome to this kepi instance. It's not properly set up yet.{% endblock %}
{% block content %}{{ blurb }}{% endblock %}

Wyświetl plik

@ -31,11 +31,8 @@ urlpatterns = [
default = tophat_views.StatusPage.as_view(),
)),
path('login/', django.contrib.auth.views.LoginView.as_view(
extra_context = {
'next': '/',
'title': 'Log in',
},
)),
path('home',
tophat_views.HomePage.as_view(),
),
]

Wyświetl plik

@ -10,7 +10,8 @@ logger = logging.getLogger(name='kepi')
from django.views import View
from django.shortcuts import render
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
import kepi.trilby_api.models as trilby_models
class RootPage(View):
@ -25,6 +26,7 @@ class RootPage(View):
context = {
'title': settings.KEPI['INSTANCE_NAME'],
'subtitle': settings.KEPI['INSTANCE_DESCRIPTION'],
'blurb': settings.KEPI['INSTANCE_BLURB'],
},
)
@ -95,3 +97,21 @@ class StatusPage(View):
)
return result
class HomePage(View):
@method_decorator(login_required)
def get(self, request,
*args, **kwargs):
logger.info("Serving home page for current user",
)
result = render(
request=request,
template_name='home-page.html',
context = {
},
)
return result

Wyświetl plik

@ -1,5 +1,5 @@
from django.apps import AppConfig
import django.apps
class TrilbyApiConfig(AppConfig):
name = 'trilby_api'
class TrilbyApiConfig(django.apps.AppConfig):
name = 'kepi.trilby_api'
default_auto_field = 'django.db.models.AutoField'

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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

Wyświetl plik

@ -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 = '<p></p>'
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 = '<p></p>'
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 '<p></p>'
return markdown.markdown(self.content)
@property
def spoiler_text_as_html(self):
if not self.spoiler_text:
return '<p></p>'
return markdown.markdown(self.spoiler_text)

Wyświetl plik

@ -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)

Wyświetl plik

@ -9,33 +9,9 @@ logger = logging.getLogger(name='kepi')
from django.dispatch import Signal
liked = Signal(
providing_args=[
'liker',
'liked',
])
unliked = Signal(
providing_args=[
'liker',
'liked',
])
followed = Signal(
providing_args=[
'follower',
'followed',
])
unfollowed = Signal(
providing_args=[
'follower',
'followed',
])
posted = Signal(
providing_args=[
])
reblogged = Signal(
)
liked = Signal()
unliked = Signal()
followed = Signal()
unfollowed = Signal()
posted = Signal()
reblogged = Signal()

Wyświetl plik

@ -1,9 +1,19 @@
from django.test import TestCase, Client
# 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 Client
from kepi.kepi.testing import KepiTestCase
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',
@ -56,7 +66,7 @@ STATUS_EXPECTED = {
'pinned': False,
}
class TrilbyTestCase(TestCase):
class TrilbyTestCase(KepiTestCase):
def setUp(self):
@ -74,11 +84,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 +102,7 @@ class TrilbyTestCase(TestCase):
path=path,
data=data,
format='json',
*args,
**kwargs,
extra=extra,
)
if expect_result is not None:
@ -155,7 +164,7 @@ def create_local_status(
result = Status(
remote_url = None,
account = posted_by,
content = content,
content_source = content,
**kwargs,
)

Wyświetl plik

@ -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,
)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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'<p>{id}</p>')
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('<p>') and detail.endswith('</p>'):
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()

Wyświetl plik

@ -1,10 +1,12 @@
from django.test import TestCase
from django.conf import settings
from kepi.trilby_api.utils import *
from kepi.kepi.testing import KepiTestCase
class Tests(TestCase):
class Tests(KepiTestCase):
def setUp(self):
super().setUp()
settings.KEPI['LOCAL_OBJECT_HOSTNAME'] = 'testserver'
def test_is_local_user_url(self):

Wyświetl plik

@ -5,48 +5,48 @@
# Licensed under the GNU Public License v2.
from django.urls import path
from .views import *
import kepi.trilby_api.views as views
urlpatterns = [
path('api/v1/instance', Instance.as_view()),
path('api/v1/instance/', Instance.as_view()), # keep tootstream happy
path('api/v1/apps', Apps.as_view()),
path('api/v1/instance', views.Instance.as_view()),
path('api/v1/instance/', views.Instance.as_view()), # keep tootstream happy
path('api/v1/apps', views.Apps.as_view()),
path('api/v1/accounts/verify_credentials', Verify_Credentials.as_view()),
path('api/v1/accounts/verify_credentials', views.VerifyCredentials.as_view()),
path('api/v1/accounts/update_credentials',
UpdateCredentials.as_view()),
views.UpdateCredentials.as_view()),
path('api/v1/accounts/search', AccountsSearch.as_view()),
path('api/v1/accounts/search', views.AccountsSearch.as_view()),
path('api/v1/accounts/<user>', User.as_view()),
path('api/v1/accounts/<user>/statuses', Statuses.as_view()),
path('api/v1/accounts/<user>/following', Following.as_view()),
path('api/v1/accounts/<user>/followers', Followers.as_view()),
path('api/v1/accounts/<user>/follow', Follow.as_view()),
path('api/v1/accounts/<user>/unfollow', Unfollow.as_view()),
path('api/v1/accounts/<user>', views.User.as_view()),
path('api/v1/accounts/<user>/statuses', views.Statuses.as_view()),
path('api/v1/accounts/<user>/following', views.Following.as_view()),
path('api/v1/accounts/<user>/followers', views.Followers.as_view()),
path('api/v1/accounts/<user>/follow', views.FollowUser.as_view()),
path('api/v1/accounts/<user>/unfollow', views.UnfollowUser.as_view()),
path('api/v1/statuses', Statuses.as_view()),
path('api/v1/statuses/<status>', SpecificStatus.as_view()),
path('api/v1/statuses/<status>/context', StatusContext.as_view()),
path('api/v1/statuses', views.Statuses.as_view()),
path('api/v1/statuses/<status>', views.SpecificStatus.as_view()),
path('api/v1/statuses/<status>/context', views.StatusContext.as_view()),
# Favourite, aka like
path('api/v1/statuses/<status>/favourite', Favourite.as_view()),
path('api/v1/statuses/<status>/unfavourite', Unfavourite.as_view()),
path('api/v1/statuses/<status>/favourited_by', StatusFavouritedBy.as_view()),
path('api/v1/statuses/<status>/favourite', views.Favourite.as_view()),
path('api/v1/statuses/<status>/unfavourite', views.Unfavourite.as_view()),
path('api/v1/statuses/<status>/favourited_by', views.StatusFavouritedBy.as_view()),
# Reblog, aka boost
path('api/v1/statuses/<status>/reblog', Reblog.as_view()),
path('api/v1/statuses/<status>/unreblog', Unreblog.as_view()),
path('api/v1/statuses/<status>/reblogged_by', StatusRebloggedBy.as_view()),
path('api/v1/statuses/<status>/reblog', views.Reblog.as_view()),
path('api/v1/statuses/<status>/unreblog', views.Unreblog.as_view()),
path('api/v1/statuses/<status>/reblogged_by', views.StatusRebloggedBy.as_view()),
path('api/v1/notifications', Notifications.as_view()),
path('api/v1/filters', Filters.as_view()),
path('api/v1/custom_emojis', Emojis.as_view()),
path('api/v1/timelines/public', PublicTimeline.as_view()),
path('api/v1/timelines/home', HomeTimeline.as_view()),
path('api/v1/notifications', views.Notifications.as_view()),
path('api/v1/filters', views.Filters.as_view()),
path('api/v1/custom_emojis', views.Emojis.as_view()),
path('api/v1/timelines/public', views.PublicTimeline.as_view()),
path('api/v1/timelines/home', views.HomeTimeline.as_view()),
path('api/v1/search', Search.as_view()),
path('api/v1/search', views.Search.as_view()),
path('users/<username>/feed', UserFeed.as_view()),
path('users/<username>/feed', views.UserFeed.as_view()),
]

Wyświetl plik

@ -1,878 +0,0 @@
# views.py
#
# Part of kepi.
# Copyright (c) 2018-2020 Marnanel Thurman.
# Licensed under the GNU Public License v2.
import logging
logger = logging.getLogger(name='kepi')
from django.db import IntegrityError, transaction
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse, Http404
from oauth2_provider.models import Application
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
import kepi.trilby_api.models as trilby_models
import kepi.trilby_api.utils as trilby_utils
from .serializers import *
from rest_framework import generics, response, mixins
from rest_framework.permissions import IsAuthenticated, \
IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import kepi.trilby_api.receivers
from kepi.bowler_pub.utils import uri_to_url
import json
import re
import random
###########################
class Instance(View):
def get(self, request, *args, **kwargs):
result = {
'uri': 'http://127.0.0.1',
'title': settings.KEPI['INSTANCE_NAME'],
'description': settings.KEPI['INSTANCE_DESCRIPTION'],
'email': settings.KEPI['CONTACT_EMAIL'],
'version': '1.0.0', # of the protocol
'urls': {},
'languages': settings.KEPI['LANGUAGES'],
'contact_account': settings.KEPI['CONTACT_ACCOUNT'],
}
return JsonResponse(result)
###########################
def error_response(status, reason):
return JsonResponse(
{
"error": reason,
},
status = status,
reason = reason,
)
###########################
class DoSomethingWithStatus(generics.GenericAPIView):
serializer_class = StatusSerializer
queryset = trilby_models.Status.objects.all()
def _do_something_with(self, the_status, request):
raise NotImplementedError()
def post(self, request, *args, **kwargs):
if request.user is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
try:
the_status = get_object_or_404(
self.get_queryset(),
id = int(kwargs['status']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
result = self._do_something_with(the_status, request)
if result is None:
result = the_status
serializer = StatusSerializer(
result,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200,
reason = 'Done',
)
class Favourite(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
try:
like = trilby_models.Like(
liker = request.user.localperson,
liked = the_status,
)
with transaction.atomic():
like.save(
send_signal = True,
)
logger.info(' -- created a Like')
except IntegrityError:
logger.info(' -- not creating a Like; it already exists')
class Unfavourite(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
try:
like = trilby_models.Like.objects.get(
liker = request.user.localperson,
liked = the_status,
)
logger.info(' -- deleting the Like: %s',
like)
like.delete()
except trilby_models.Like.DoesNotExist:
logger.info(' -- not unliking; the Like doesn\'t exists')
class Reblog(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
# Mastodon allows a "visibility" param here
# but currently doesn't use it in the UI
# Mastodon doesn't say whether a user can
# reblog the same status more than once:
# https://github.com/tootsuite/mastodon/issues/13479
# For now, I'm assuming that you can.
content = 'RT {}'.format(the_status.content)
new_status = trilby_models.Status(
# Fields which are different in a reblog:
account = request.user.localperson,
content = content,
reblog_of = the_status,
# Fields which are just copied in:
sensitive = the_status.sensitive,
spoiler_text = the_status.spoiler_text,
visibility = the_status.visibility,
language = the_status.language,
in_reply_to = the_status.in_reply_to,
)
with transaction.atomic():
new_status.save(
send_signal = True,
)
logger.info(' -- created a reblog')
return new_status
class Unreblog(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
# See the note in "Reblog" about whether
# multiple reblogs of the same status
# are allowed.
reblogs = trilby_models.Status.objects.filter(
reblog_of = the_status,
account = request.user.localperson,
)
if not reblogs.exists():
raise Http404("No such reblog")
reblogs.delete()
logger.info(' -- deleting reblogs')
###########################
class DoSomethingWithPerson(generics.GenericAPIView):
serializer_class = UserSerializer
queryset = trilby_models.Person.objects.all()
def _do_something_with(self, the_person, request):
raise NotImplementedError()
def post(self, request, *args, **kwargs):
if request.user is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
try:
the_person = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
result = self._do_something_with(the_person, request)
if result is None:
result = the_person
serializer = UserSerializer(
result,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200,
reason = 'Done',
)
class Follow(DoSomethingWithPerson):
def _do_something_with(self, the_person, request):
try:
if the_person.auto_follow:
offer = None
else:
number = random.randint(0, 0xffffffff)
offer = uri_to_url(settings.KEPI['FOLLOW_REQUEST_LINK'] % {
'username': request.user.username,
'number': number,
})
follow = trilby_models.Follow(
follower = request.user.localperson,
following = the_person,
offer = offer,
)
with transaction.atomic():
follow.save(
send_signal = True,
)
logger.info(' -- follow: %s', follow)
logger.debug(' -- offer ID: %s', offer)
if the_person.auto_follow:
follow_back = trilby_models.Follow(
follower = the_person,
following = request.user.localperson,
offer = None,
)
with transaction.atomic():
follow_back.save(
send_signal = True,
)
logger.info(' -- follow back: %s', follow_back)
return the_person
except IntegrityError:
logger.info(' -- not creating a follow; it already exists')
class Unfollow(DoSomethingWithPerson):
def _do_something_with(self, the_person, request):
try:
follow = trilby_models.Follow.objects.get(
follower = request.user.localperson,
following = the_person,
)
logger.info(' -- unfollowing: %s', follow)
with transaction.atomic():
follow.delete(
send_signal = True,
)
return the_person
except trilby_models.Follow.DoesNotExist:
logger.info(' -- not unfollowing; they weren\'t following '+\
'in the first place')
class UpdateCredentials(generics.GenericAPIView):
def patch(self, request, *args, **kwargs):
if request.user is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
who = request.user.localperson
# The Mastodon spec doesn't say what to do
# if the user submits field names which don't
# exist!
unknown_fields = []
# FIXME: the data in "v" needs cleaning.
logger.info('-- updating user: %s', who)
for f,v in request.data.items():
logger.info(' -- setting %s = %s', f, v)
if f=='discoverable':
raise Http404("discoverable is not yet supported")
elif f=='bot':
who.bot = v
elif f=='display_name':
who.display_name = v
elif f=='note':
who.note = v
elif f=='avatar':
raise Http404("images are not yet supported")
elif f=='header':
raise Http404("images are not yet supported")
elif f=='locked':
who.locked = v
elif f=='source[privacy]':
who.default_visibility = v
elif f=='source[sensitive]':
who.default_sensitive = v
elif f=='source[language]':
who.language = v
elif f=='fields_attributes':
raise Http404("fields are not yet supported")
else:
logger.info(' -- field does not exist')
unknown_fields.append(f)
if unknown_fields:
logger.info(' -- aborting because of unknown fields')
raise Http404(f"some fields do not exist: {unknown_fields}")
who.save()
logger.info(' -- done.')
serializer = UserSerializerWithSource(
who,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200,
reason = 'Done',
)
###########################
def fix_oauth2_redirects():
"""
Called from kepi.kepi.urls to fix a silly oversight
in oauth2_provider. This isn't elegant.
oauth2_provider.http.OAuth2ResponseRedirect checks the
URL it's redirecting to, and raises DisallowedRedirect
if it's not a recognised protocol. But this breaks apps
like Tusky, which registers its own protocol with Android
and then redirects to that in order to bring itself
back once authentication's done.
There's no way to fix this as a user of that package.
Hence, we have to monkey-patch that class.
"""
def fake_validate_redirect(not_self, redirect_to):
return True
from oauth2_provider.http import OAuth2ResponseRedirect as OA2RR
OA2RR.validate_redirect = fake_validate_redirect
logger.info("Monkey-patched %s.", OA2RR)
###########################
class Apps(View):
def post(self, request, *args, **kwargs):
new_app = Application(
name = request.POST['client_name'],
redirect_uris = request.POST['redirect_uris'],
client_type = 'confidential',
authorization_grant_type = 'authorization-code',
user = None, # don't need to be logged in
)
new_app.save()
result = {
'id': new_app.id,
'client_id': new_app.client_id,
'client_secret': new_app.client_secret,
}
return JsonResponse(result)
class Verify_Credentials(generics.GenericAPIView):
queryset = TrilbyUser.objects.all()
def get(self, request, *args, **kwargs):
serializer = UserSerializerWithSource(request.user.localperson)
return JsonResponse(serializer.data)
class User(generics.GenericAPIView):
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
try:
whoever = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
serializer = UserSerializer(whoever)
return JsonResponse(serializer.data)
class SpecificStatus(generics.GenericAPIView):
queryset = trilby_models.Status.objects.filter(remote_url=None)
serializer_class = StatusSerializer
lookup_field = 'status'
permission_classes = (IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
the_status = get_object_or_404(
self.get_queryset(),
id = int(kwargs['status']),
)
serializer = StatusSerializer(
the_status,
context = {
'request': request,
},
)
response = JsonResponse(serializer.data)
return response
def delete(self, request, *args, **kwargs):
if 'status' not in kwargs:
return error_response(404, 'Can\'t delete all statuses at once')
the_status = get_object_or_404(
self.get_queryset(),
id = int(kwargs['status']),
)
if the_status.account != request.user.localperson:
return error_response(404, # sic
'That isn\'t yours to delete')
serializer = StatusSerializer(
the_status,
context = {
'request': request,
},
)
response = JsonResponse(serializer.data)
the_status.delete()
return response
class Statuses(generics.ListCreateAPIView,
):
queryset = trilby_models.Status.objects.filter(remote_url=None)
serializer_class = StatusSerializer
permission_classes = (IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
try:
the_person = get_object_or_404(
trilby_models.Person,
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
logger.info('Looking up all visible statuses, for %s',
the_person)
queryset = self.get_queryset().filter(
account = the_person,
)
serializer = StatusSerializer(
queryset,
context = {
'request': request,
},
many = True,
)
return JsonResponse(serializer.data,
safe = False, # it's a list
)
def create(self, request, *args, **kwargs):
data = request.data
if 'status' not in data and 'media_ids' not in data:
return HttpResponse(
status = 400,
content = 'You must supply a status or some media IDs',
)
status = trilby_models.Status(
account = request.user.localperson,
content = data.get('status', ''),
sensitive = data.get('sensitive', False),
spoiler_text = data.get('spoiler_text', ''),
visibility = data.get('visibility', 'public'),
language = data.get('language',
settings.KEPI['LANGUAGES'][0]),
# FIXME: in_reply_to
# FIXME: media_ids
# FIXME: idempotency_key
)
status.save(
send_signal = True,
)
serializer = StatusSerializer(
status,
partial = True,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200, # should really be 201 but the spec says 200
reason = 'Hot off the press',
)
class StatusContext(generics.ListCreateAPIView):
queryset = trilby_models.Status.objects.all()
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
status = queryset.get(id=int(kwargs['status']))
serializer = StatusContextSerializer(status)
return JsonResponse(serializer.data)
class StatusFavouritedBy(generics.ListCreateAPIView):
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
status = trilby_models.Status.objects.get(id=int(kwargs['status']))
status.save()
people = queryset.filter(
like__liked = status,
)
serializer = UserSerializer(people,
many=True)
return JsonResponse(serializer.data,
safe=False, # it's a list
)
class StatusRebloggedBy(generics.ListCreateAPIView):
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
status = trilby_models.Status.objects.get(id=int(kwargs['status']))
people = queryset.filter(
poster__reblog_of = status,
)
serializer = UserSerializer(people,
many=True)
return JsonResponse(serializer.data,
safe=False, # it's a list
)
class AbstractTimeline(generics.ListAPIView):
serializer_class = StatusSerializer
permission_classes = [
IsAuthenticated,
]
def get_queryset(self, request):
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)
PUBLIC_TIMELINE_SLICE_LENGTH = 20
class PublicTimeline(AbstractTimeline):
permission_classes = ()
def get_queryset(self, request):
result = trilby_models.Status.objects.filter(
visibility = trilby_utils.VISIBILITY_PUBLIC,
)[:PUBLIC_TIMELINE_SLICE_LENGTH]
return result
class HomeTimeline(AbstractTimeline):
permission_classes = [
IsAuthenticated,
]
def get_queryset(self, request):
result = request.user.localperson.inbox
logger.debug("Home timeline is %s",
result)
return result
########################################
# TODO stub
class AccountsSearch(generics.ListAPIView):
queryset = trilby_models.Person.objects.all()
serializer_class = UserSerializer
permission_classes = [
IsAuthenticated,
]
########################################
# TODO stub
class Search(View):
permission_classes = [
IsAuthenticated,
]
def get(self, request, *args, **kwargs):
result = {
'accounts': [],
'statuses': [],
'hashtags': [],
}
return JsonResponse(result)
########################################
class UserFeed(View):
permission_classes = ()
def get(self, request, username, *args, **kwargs):
try:
the_person = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
context = {
'self': request.build_absolute_uri(),
'user': the_person,
'statuses': the_person.outbox,
'server_name': settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
}
result = render(
request=request,
template_name='account.atom.xml',
context=context,
content_type='application/atom+xml',
)
links = ', '.join(
[ '<{}>; rel="{}"; type="{}"'.format(
settings.KEPI[uri].format(
hostname = settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
username = the_person.id[1:],
),
rel, mimetype)
for uri, rel, mimetype in
[
('USER_WEBFINGER_URLS',
'lrdd',
'application/xrd+xml',
),
('USER_FEED_URLS',
'alternate',
'application/atom+xml',
),
('USER_FEED_URLS',
'alternate',
'application/activity+json',
),
]
])
result['Link'] = links
return result
########################################
class Notifications(generics.ListAPIView):
serializer_class = NotificationSerializer
permission_classes = [
IsAuthenticated,
]
def list(self, request):
queryset = Notification.objects.filter(
for_account = request.user.localperson,
)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
########################################
class Emojis(View):
# FIXME
def get(self, request, *args, **kwargs):
return JsonResponse([],
safe=False)
class Filters(View):
# FIXME
def get(self, request, *args, **kwargs):
return JsonResponse([],
safe=False)
########################################
class Followers_or_Following(generics.GenericAPIView):
serializer_class = UserSerializer
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
params = request.data
if request.user.localperson is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
try:
the_person = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
queryset = self._get_list_for(the_person)
if 'max_id' in params:
queryset = queryset.filter(
id__le = params['max_id'],
)
if 'since_id' in params:
queryset = queryset.filter(
id__gt = params['since_id'],
)
if 'limit' in params:
queryset = queryset[:params['limit']]
serializer = UserSerializer(
queryset,
many = True,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
safe = False, # it's a list
status = 200,
reason = 'Done',
)
class Followers(Followers_or_Following):
def _get_list_for(self, the_person):
return the_person.followers
class Following(Followers_or_Following):
def _get_list_for(self, the_person):
return the_person.following

Wyświetl plik

@ -0,0 +1,38 @@
from .oauth import *
from .other import *
from .persons import *
from .statuses import *
from .timelines import *
__all__ = [
'AbstractTimeline',
'AccountsSearch',
'Apps',
'DoSomethingWithPerson',
'DoSomethingWithStatus',
'Emojis',
'Favourite',
'Filters',
'FollowUser',
'Followers',
'Followers_or_Following',
'Following',
'HomeTimeline',
'Instance',
'Notifications',
'PublicTimeline',
'Reblog',
'Search',
'SpecificStatus',
'StatusContext',
'Statuses',
'StatusFavouritedBy',
'StatusRebloggedBy',
'Unfavourite',
'UnfollowUser',
'Unreblog',
'UpdateCredentials',
'User',
'UserFeed',
'VerifyCredentials',
]

Wyświetl plik

@ -0,0 +1,81 @@
# trilby_api/views/oauth.py
#
# Part of kepi.
# Copyright (c) 2018-2021 Marnanel Thurman.
# Licensed under the GNU Public License v2.
import logging
logger = logging.getLogger(name='kepi')
from django.db import IntegrityError, transaction
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse, Http404
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
import kepi.trilby_api.models as trilby_models
import kepi.trilby_api.utils as trilby_utils
from kepi.trilby_api.serializers import *
from rest_framework import generics, response, mixins
from rest_framework.permissions import IsAuthenticated, \
IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import kepi.trilby_api.receivers
from kepi.bowler_pub.utils import uri_to_url
import json
import re
import random
def fix_oauth2_redirects():
"""
Called from kepi.kepi.urls to fix a silly oversight
in oauth2_provider. This isn't elegant.
oauth2_provider.http.OAuth2ResponseRedirect checks the
URL it's redirecting to, and raises DisallowedRedirect
if it's not a recognised protocol. But this breaks apps
like Tusky, which registers its own protocol with Android
and then redirects to that in order to bring itself
back once authentication's done.
There's no way to fix this as a user of that package.
Hence, we have to monkey-patch that class.
"""
def fake_validate_redirect(not_self, redirect_to):
return True
from oauth2_provider.http import OAuth2ResponseRedirect as OA2RR
OA2RR.validate_redirect = fake_validate_redirect
logger.info("Monkey-patched %s.", OA2RR)
###########################
class Apps(View):
def post(self, request, *args, **kwargs):
new_app = Application(
name = request.POST['client_name'],
redirect_uris = request.POST['redirect_uris'],
client_type = 'confidential',
authorization_grant_type = 'authorization-code',
user = None, # don't need to be logged in
)
new_app.save()
result = {
'id': new_app.id,
'client_id': new_app.client_id,
'client_secret': new_app.client_secret,
}
return JsonResponse(result)

Wyświetl plik

@ -0,0 +1,90 @@
# trilby_api/views/other.py
#
# Part of kepi.
# Copyright (c) 2018-2021 Marnanel Thurman.
# Licensed under the GNU Public License v2.
import logging
logger = logging.getLogger(name='kepi')
from django.db import IntegrityError, transaction
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse, Http404
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
import kepi.trilby_api.models as trilby_models
import kepi.trilby_api.utils as trilby_utils
from kepi.trilby_api.serializers import *
from rest_framework import generics, response, mixins
from rest_framework.permissions import IsAuthenticated, \
IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import kepi.trilby_api.receivers
from kepi.bowler_pub.utils import uri_to_url
import json
import re
import random
class Instance(View):
def get(self, request, *args, **kwargs):
result = {
'uri': 'http://127.0.0.1',
'title': settings.KEPI['INSTANCE_NAME'],
'description': settings.KEPI['INSTANCE_DESCRIPTION'],
'email': settings.KEPI['CONTACT_EMAIL'],
'version': '1.0.0', # of the protocol
'urls': {},
'languages': settings.KEPI['LANGUAGES'],
'contact_account': settings.KEPI['CONTACT_ACCOUNT'],
}
return JsonResponse(result)
class Emojis(View):
# FIXME
def get(self, request, *args, **kwargs):
return JsonResponse([],
safe=False)
class Filters(View):
# FIXME
def get(self, request, *args, **kwargs):
return JsonResponse([],
safe=False)
class Search(View):
# FIXME
permission_classes = [
IsAuthenticated,
]
def get(self, request, *args, **kwargs):
result = {
'accounts': [],
'statuses': [],
'hashtags': [],
}
return JsonResponse(result)
class AccountsSearch(generics.ListAPIView):
# FIXME
queryset = trilby_models.Person.objects.all()
serializer_class = UserSerializer
permission_classes = [
IsAuthenticated,
]

Wyświetl plik

@ -0,0 +1,303 @@
# trilby_api/views/persons.py
#
# Part of kepi.
# Copyright (c) 2018-2021 Marnanel Thurman.
# Licensed under the GNU Public License v2.
import logging
logger = logging.getLogger(name='kepi')
from django.db import IntegrityError, transaction
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse, Http404
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
import kepi.trilby_api.models as trilby_models
import kepi.trilby_api.utils as trilby_utils
from kepi.trilby_api.serializers import *
from rest_framework import generics, response, mixins
from rest_framework.permissions import IsAuthenticated, \
IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import kepi.trilby_api.receivers
from kepi.bowler_pub.utils import uri_to_url
import json
import re
import random
class DoSomethingWithPerson(generics.GenericAPIView):
serializer_class = UserSerializer
queryset = trilby_models.Person.objects.all()
def _do_something_with(self, the_person, request):
raise NotImplementedError()
def post(self, request, *args, **kwargs):
if request.user is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
try:
the_person = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
result = self._do_something_with(the_person, request)
if result is None:
result = the_person
serializer = UserSerializer(
result,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200,
reason = 'Done',
)
class FollowUser(DoSomethingWithPerson):
def _do_something_with(self, the_person, request):
try:
if the_person.auto_follow:
offer = None
else:
number = random.randint(0, 0xffffffff)
offer = uri_to_url(settings.KEPI['FOLLOW_REQUEST_LINK'] % {
'username': request.user.username,
'number': number,
})
follow = trilby_models.Follow(
follower = request.user.localperson,
following = the_person,
offer = offer,
)
with transaction.atomic():
follow.save(
send_signal = True,
)
logger.info(' -- follow: %s', follow)
logger.debug(' -- offer ID: %s', offer)
if the_person.auto_follow:
follow_back = trilby_models.Follow(
follower = the_person,
following = request.user.localperson,
offer = None,
)
with transaction.atomic():
follow_back.save(
send_signal = True,
)
logger.info(' -- follow back: %s', follow_back)
return the_person
except IntegrityError:
logger.info(' -- not creating a follow; it already exists')
class UnfollowUser(DoSomethingWithPerson):
def _do_something_with(self, the_person, request):
try:
follow = trilby_models.Follow.objects.get(
follower = request.user.localperson,
following = the_person,
)
logger.info(' -- unfollowing: %s', follow)
with transaction.atomic():
follow.delete(
send_signal = True,
)
return the_person
except trilby_models.Follow.DoesNotExist:
logger.info(' -- not unfollowing; they weren\'t following '+\
'in the first place')
###########################
class VerifyCredentials(generics.GenericAPIView):
queryset = TrilbyUser.objects.all()
def get(self, request, *args, **kwargs):
serializer = UserSerializerWithSource(request.user.localperson)
return JsonResponse(serializer.data)
###########################
class User(generics.GenericAPIView):
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
try:
whoever = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
serializer = UserSerializer(whoever)
return JsonResponse(serializer.data)
#######################################
class Followers_or_Following(generics.GenericAPIView):
serializer_class = UserSerializer
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
params = request.data
if request.user.localperson is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
try:
the_person = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
queryset = self._get_list_for(the_person)
if 'max_id' in params:
queryset = queryset.filter(
id__le = params['max_id'],
)
if 'since_id' in params:
queryset = queryset.filter(
id__gt = params['since_id'],
)
if 'limit' in params:
queryset = queryset[:params['limit']]
serializer = UserSerializer(
queryset,
many = True,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
safe = False, # it's a list
status = 200,
reason = 'Done',
)
class Followers(Followers_or_Following):
def _get_list_for(self, the_person):
return the_person.followers
class Following(Followers_or_Following):
def _get_list_for(self, the_person):
return the_person.following
###########################
class UpdateCredentials(generics.GenericAPIView):
def patch(self, request, *args, **kwargs):
if request.user is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
who = request.user.localperson
# The Mastodon spec doesn't say what to do
# if the user submits field names which don't
# exist!
unknown_fields = []
# FIXME: the data in "v" needs cleaning.
logger.info('-- updating user: %s', who)
for f,v in request.data.items():
logger.info(' -- setting %s = %s', f, v)
if f=='discoverable':
raise Http404("discoverable is not yet supported")
elif f=='bot':
who.bot = v
elif f=='display_name':
who.display_name = v
elif f=='note':
who.note = v
elif f=='avatar':
raise Http404("images are not yet supported")
elif f=='header':
raise Http404("images are not yet supported")
elif f=='locked':
who.locked = v
elif f=='source[privacy]':
who.default_visibility = v
elif f=='source[sensitive]':
who.default_sensitive = v
elif f=='source[language]':
who.language = v
elif f=='fields_attributes':
raise Http404("fields are not yet supported")
else:
logger.info(' -- field does not exist')
unknown_fields.append(f)
if unknown_fields:
logger.info(' -- aborting because of unknown fields')
raise Http404(f"some fields do not exist: {unknown_fields}")
who.save()
logger.info(' -- done.')
serializer = UserSerializerWithSource(
who,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200,
reason = 'Done',
)

Wyświetl plik

@ -0,0 +1,395 @@
# trilby_api/views/statuses.py
#
# Part of kepi.
# Copyright (c) 2018-2021 Marnanel Thurman.
# Licensed under the GNU Public License v2.
import logging
logger = logging.getLogger(name='kepi')
from django.db import IntegrityError, transaction
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse, Http404
from oauth2_provider.models import Application
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
import kepi.trilby_api.models as trilby_models
import kepi.trilby_api.utils as trilby_utils
from kepi.trilby_api.serializers import *
from rest_framework import generics, response, mixins
from rest_framework.permissions import IsAuthenticated, \
IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import kepi.trilby_api.receivers
from kepi.bowler_pub.utils import uri_to_url
import json
import re
import random
###########################
def error_response(status, reason):
return JsonResponse(
{
"error": reason,
},
status = status,
reason = reason,
)
###########################
class DoSomethingWithStatus(generics.GenericAPIView):
serializer_class = StatusSerializer
queryset = trilby_models.Status.objects.all()
def _do_something_with(self, the_status, request):
raise NotImplementedError()
def post(self, request, *args, **kwargs):
if request.user is None:
logger.debug(' -- user not logged in')
return error_response(401, 'Not logged in')
try:
the_status = get_object_or_404(
self.get_queryset(),
id = int(kwargs['status']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
result = self._do_something_with(the_status, request)
if result is None:
result = the_status
serializer = StatusSerializer(
result,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200,
reason = 'Done',
)
class Favourite(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
try:
like = trilby_models.Like(
liker = request.user.localperson,
liked = the_status,
)
with transaction.atomic():
like.save(
send_signal = True,
)
logger.info(' -- created a Like')
except IntegrityError:
logger.info(' -- not creating a Like; it already exists')
class Unfavourite(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
try:
like = trilby_models.Like.objects.get(
liker = request.user.localperson,
liked = the_status,
)
logger.info(' -- deleting the Like: %s',
like)
like.delete()
except trilby_models.Like.DoesNotExist:
logger.info(' -- not unliking; the Like doesn\'t exists')
class Reblog(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
# Mastodon allows a "visibility" param here
# but currently doesn't use it in the UI
# Mastodon doesn't say whether a user can
# reblog the same status more than once:
# https://github.com/tootsuite/mastodon/issues/13479
# For now, I'm assuming that you can.
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_source = content_source,
reblog_of = the_status,
# Fields which are just copied in:
sensitive = the_status.sensitive,
spoiler_source = the_status.spoiler_source,
visibility = the_status.visibility,
language = the_status.language,
in_reply_to = the_status.in_reply_to,
)
with transaction.atomic():
new_status.save(
send_signal = True,
)
logger.info(' -- created a reblog')
return new_status
class Unreblog(DoSomethingWithStatus):
def _do_something_with(self, the_status, request):
# See the note in "Reblog" about whether
# multiple reblogs of the same status
# are allowed.
reblogs = trilby_models.Status.objects.filter(
reblog_of = the_status,
account = request.user.localperson,
)
if not reblogs.exists():
raise Http404("No such reblog")
reblogs.delete()
logger.info(' -- deleting reblogs')
###########################
class SpecificStatus(generics.GenericAPIView):
queryset = trilby_models.Status.objects.filter(remote_url=None)
serializer_class = StatusSerializer
lookup_field = 'status'
permission_classes = (IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
the_status = get_object_or_404(
self.get_queryset(),
id = int(kwargs['status']),
)
serializer = StatusSerializer(
the_status,
context = {
'request': request,
},
)
response = JsonResponse(serializer.data)
return response
def delete(self, request, *args, **kwargs):
if 'status' not in kwargs:
return error_response(404, 'Can\'t delete all statuses at once')
the_status = get_object_or_404(
self.get_queryset(),
id = int(kwargs['status']),
)
if the_status.account != request.user.localperson:
return error_response(404, # sic
'That isn\'t yours to delete')
serializer = StatusSerializer(
the_status,
context = {
'request': request,
},
)
response = JsonResponse(serializer.data)
the_status.delete()
return response
class Statuses(generics.ListCreateAPIView,
):
queryset = trilby_models.Status.objects.filter(remote_url=None)
serializer_class = StatusSerializer
permission_classes = (IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
try:
the_person = get_object_or_404(
trilby_models.Person,
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
logger.info('Looking up all visible statuses, for %s',
the_person)
queryset = self.get_queryset().filter(
account = the_person,
)
serializer = StatusSerializer(
queryset,
context = {
'request': request,
},
many = True,
)
return JsonResponse(serializer.data,
safe = False, # it's a list
)
def create(self, request, *args, **kwargs):
data = request.data
if 'status' not in data and 'media_ids' not in data:
return HttpResponse(
status = 400,
content = 'You must supply a status or some media IDs',
)
status = trilby_models.Status(
account = request.user.localperson,
content_source = data.get('status', ''),
sensitive = data.get('sensitive', False),
spoiler_source = data.get('spoiler_text', ''),
visibility = data.get('visibility', 'public'),
language = data.get('language',
settings.KEPI['LANGUAGES'][0]),
# FIXME: in_reply_to
# FIXME: media_ids
# FIXME: idempotency_key
)
status.save(
send_signal = True,
)
serializer = StatusSerializer(
status,
partial = True,
context = {
'request': request,
},
)
return JsonResponse(
serializer.data,
status = 200, # should really be 201 but the spec says 200
reason = 'Hot off the press',
)
class StatusContext(generics.ListCreateAPIView):
queryset = trilby_models.Status.objects.all()
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
status = queryset.get(id=int(kwargs['status']))
serializer = StatusContextSerializer(status)
return JsonResponse(serializer.data)
class StatusFavouritedBy(generics.ListCreateAPIView):
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
status = trilby_models.Status.objects.get(id=int(kwargs['status']))
status.save()
people = queryset.filter(
like__liked = status,
)
serializer = UserSerializer(people,
many=True)
return JsonResponse(serializer.data,
safe=False, # it's a list
)
class StatusRebloggedBy(generics.ListCreateAPIView):
queryset = trilby_models.Person.objects.all()
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
status = trilby_models.Status.objects.get(id=int(kwargs['status']))
people = queryset.filter(
poster__reblog_of = status,
)
serializer = UserSerializer(people,
many=True)
return JsonResponse(serializer.data,
safe=False, # it's a list
)
########################################
# TODO stub
########################################
class Notifications(generics.ListAPIView):
serializer_class = NotificationSerializer
permission_classes = [
IsAuthenticated,
]
def list(self, request):
queryset = Notification.objects.filter(
for_account = request.user.localperson,
)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)

Wyświetl plik

@ -0,0 +1,195 @@
# trilby_api/views/timelines.py
#
# Part of kepi.
# Copyright (c) 2018-2021 Marnanel Thurman.
# Licensed under the GNU Public License v2.
import logging
logger = logging.getLogger(name='kepi')
from django.db import IntegrityError, transaction
from django.shortcuts import render, get_object_or_404
from django.views import View
from django.http import HttpResponse, JsonResponse, Http404
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.datastructures import MultiValueDictKeyError
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
import kepi.trilby_api.models as trilby_models
import kepi.trilby_api.utils as trilby_utils
from kepi.trilby_api.serializers import *
from rest_framework import generics, response, mixins
from rest_framework.permissions import IsAuthenticated, \
IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
import kepi.trilby_api.receivers
from kepi.bowler_pub.utils import uri_to_url
import json
import re
import random
DEFAULT_TIMELINE_SLICE_LENGTH = 20
class AbstractTimeline(generics.ListAPIView):
serializer_class = StatusSerializer
permission_classes = [
IsAuthenticated,
]
def get_queryset(self):
raise NotImplementedError("cannot query abstract timeline")
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,
):
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):
result = trilby_models.Status.objects.filter(
visibility = trilby_utils.VISIBILITY_PUBLIC,
)
return result
class HomeTimeline(AbstractTimeline):
permission_classes = [
IsAuthenticated,
]
def get_queryset(self):
result = self.request.user.localperson.inbox
logger.debug("Home timeline is %s",
result)
return result
########################################
class UserFeed(View):
permission_classes = ()
def get(self, request, username, *args, **kwargs):
try:
the_person = get_object_or_404(
self.get_queryset(),
id = int(kwargs['user']),
)
except ValueError:
return error_response(404, 'Non-decimal ID')
context = {
'self': request.build_absolute_uri(),
'user': the_person,
'statuses': the_person.outbox,
'server_name': settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
}
result = render(
request=request,
template_name='account.atom.xml',
context=context,
content_type='application/atom+xml',
)
links = ', '.join(
[ '<{}>; rel="{}"; type="{}"'.format(
settings.KEPI[uri].format(
hostname = settings.KEPI['LOCAL_OBJECT_HOSTNAME'],
username = the_person.id[1:],
),
rel, mimetype)
for uri, rel, mimetype in
[
('USER_WEBFINGER_URLS',
'lrdd',
'application/xrd+xml',
),
('USER_FEED_URLS',
'alternate',
'application/atom+xml',
),
('USER_FEED_URLS',
'alternate',
'application/activity+json',
),
]
])
result['Link'] = links
return result

Wyświetl plik

@ -6,7 +6,6 @@ requests-http-signature
cryptography
pillow
celery>4.0.0
django-celery
httpretty
httpsig
django-celery-results