kopia lustrzana https://gitlab.com/marnanel/chapeau
Porównaj commity
38 Commity
eb4ec809bb
...
main
Autor | SHA1 | Data |
---|---|---|
Marnanel Thurman | c8a5b5308b | |
Marnanel Thurman | 94309cc77d | |
Marnanel Thurman | b00f844d84 | |
Marnanel Thurman | 8a0deb6c93 | |
Marnanel Thurman | 4d63fd669d | |
Thomas Thurman | 07875e03ab | |
Marnanel Thurman | 77b45772bb | |
Marnanel Thurman | 7233d203bd | |
Marnanel Thurman | 02688a8422 | |
Marnanel Thurman | ebc4052495 | |
Marnanel Thurman | 6174859722 | |
Marnanel Thurman | 71c6d26ad8 | |
Marnanel Thurman | 574176b42b | |
Marnanel Thurman | a5848e45e2 | |
Marnanel Thurman | 46b3dcfbf7 | |
Marnanel Thurman | 31dede8c2c | |
Marnanel Thurman | 3ad00cfae0 | |
Marnanel Thurman | 37d53b2e4e | |
Marnanel Thurman | 061ce40101 | |
Marnanel Thurman | 5bf75d6c98 | |
Marnanel Thurman | dbdad70376 | |
Marnanel Thurman | e77ce337c4 | |
Marnanel Thurman | 8eb2b2468d | |
Marnanel Thurman | d4af44913b | |
Marnanel Thurman | a9d03dd280 | |
Marnanel Thurman | 6ae898af03 | |
Marnanel Thurman | 3a3ce2fae2 | |
Marnanel Thurman | 17519f62cb | |
Marnanel Thurman | f40a6d862d | |
Marnanel Thurman | 312b3760fc | |
Marnanel Thurman | 63955031e5 | |
Marnanel Thurman | 30f445aa3b | |
Marnanel Thurman | 60b70f9cee | |
Marnanel Thurman | 98a5bde0b2 | |
Marnanel Thurman | 4ef96ea6db | |
Marnanel Thurman | 7e763ed4bf | |
Marnanel Thurman | 99981e3baf | |
Marnanel Thurman | 2198350394 |
|
@ -7,6 +7,7 @@ __pycache__
|
|||
*.sqlite3
|
||||
build/
|
||||
dist/
|
||||
static
|
||||
examples
|
||||
kepi/static
|
||||
kepi/kepi/local_config.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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
from django.dispatch import Signal
|
||||
|
||||
received = Signal(
|
||||
providing_args=[
|
||||
])
|
||||
received = Signal()
|
||||
|
|
|
@ -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',
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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' 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
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app', )
|
|
@ -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}')
|
|
@ -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 *
|
||||
|
|
|
@ -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)
|
|
@ -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)),
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import django.apps
|
||||
|
||||
class SombreroApiConfig(django.apps.AppConfig):
|
||||
name = 'kepi.sombrero_sendpub'
|
||||
default_auto_field = 'django.db.models.AutoField'
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}home{% endblock %}
|
|
@ -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">👒{{ 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>
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
|
||||
{% if form.errors %}
|
||||
<p>Your username and password didn't match. Please try again.</p>
|
||||
{% endif %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()),
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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',
|
||||
]
|
|
@ -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)
|
||||
|
||||
|
|
@ -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,
|
||||
]
|
|
@ -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',
|
||||
)
|
|
@ -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)
|
|
@ -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
|
|
@ -6,7 +6,6 @@ requests-http-signature
|
|||
cryptography
|
||||
pillow
|
||||
celery>4.0.0
|
||||
django-celery
|
||||
httpretty
|
||||
httpsig
|
||||
django-celery-results
|
||||
|
|
Ładowanie…
Reference in New Issue