Timelines working
This commit is contained in:
parent
1017c71ba1
commit
3e062aed36
|
@ -708,3 +708,50 @@ class Post(StatorModel):
|
||||||
canonicalise(response.json(), include_security=True),
|
canonicalise(response.json(), include_security=True),
|
||||||
update=True,
|
update=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
### Mastodon API ###
|
||||||
|
|
||||||
|
def to_mastodon_json(self):
|
||||||
|
reply_parent = None
|
||||||
|
if self.in_reply_to:
|
||||||
|
reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first()
|
||||||
|
return {
|
||||||
|
"id": self.pk,
|
||||||
|
"uri": self.object_uri,
|
||||||
|
"created_at": format_ld_date(self.published),
|
||||||
|
"account": self.author.to_mastodon_json(),
|
||||||
|
"content": self.safe_content_remote(),
|
||||||
|
"visibility": "public",
|
||||||
|
"sensitive": self.sensitive,
|
||||||
|
"spoiler_text": self.summary or "",
|
||||||
|
"media_attachments": [
|
||||||
|
attachment.to_mastodon_json() for attachment in self.attachments.all()
|
||||||
|
],
|
||||||
|
"mentions": [
|
||||||
|
{
|
||||||
|
"id": mention.id,
|
||||||
|
"username": mention.username,
|
||||||
|
"url": mention.absolute_profile_uri(),
|
||||||
|
"acct": mention.handle,
|
||||||
|
}
|
||||||
|
for mention in self.mentions.all()
|
||||||
|
],
|
||||||
|
"tags": (
|
||||||
|
[{"name": tag, "url": "/tag/{tag}/"} for tag in self.hashtags]
|
||||||
|
if self.hashtags
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
"emojis": [],
|
||||||
|
"reblogs_count": self.interactions.filter(type="boost").count(),
|
||||||
|
"favourites_count": self.interactions.filter(type="like").count(),
|
||||||
|
"replies_count": 0,
|
||||||
|
"url": self.absolute_object_uri(),
|
||||||
|
"in_reply_to_id": reply_parent.pk if reply_parent else None,
|
||||||
|
"in_reply_to_account_id": reply_parent.author.pk if reply_parent else None,
|
||||||
|
"reblog": None,
|
||||||
|
"poll": None,
|
||||||
|
"card": None,
|
||||||
|
"language": None,
|
||||||
|
"text": self.safe_content_plain(),
|
||||||
|
"edited_at": format_ld_date(self.edited) if self.edited else None,
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from core.uploads import upload_namer
|
from core.uploads import upload_namer
|
||||||
|
@ -77,13 +78,13 @@ class PostAttachment(StatorModel):
|
||||||
elif self.file:
|
elif self.file:
|
||||||
return self.file.url
|
return self.file.url
|
||||||
else:
|
else:
|
||||||
return f"/proxy/post_attachment/{self.pk}/"
|
return f"https://{settings.MAIN_DOMAIN}/proxy/post_attachment/{self.pk}/"
|
||||||
|
|
||||||
def full_url(self):
|
def full_url(self):
|
||||||
if self.file:
|
if self.file:
|
||||||
return self.file.url
|
return self.file.url
|
||||||
else:
|
else:
|
||||||
return f"/proxy/post_attachment/{self.pk}/"
|
return f"https://{settings.MAIN_DOMAIN}/proxy/post_attachment/{self.pk}/"
|
||||||
|
|
||||||
### ActivityPub ###
|
### ActivityPub ###
|
||||||
|
|
||||||
|
@ -97,3 +98,28 @@ class PostAttachment(StatorModel):
|
||||||
"mediaType": self.mimetype,
|
"mediaType": self.mimetype,
|
||||||
"http://joinmastodon.org/ns#focalPoint": [0, 0],
|
"http://joinmastodon.org/ns#focalPoint": [0, 0],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
### Mastodon Client API ###
|
||||||
|
|
||||||
|
def to_mastodon_json(self):
|
||||||
|
return {
|
||||||
|
"id": self.pk,
|
||||||
|
"type": "image" if self.is_image() else "unknown",
|
||||||
|
"url": self.full_url(),
|
||||||
|
"preview_url": self.thumbnail_url(),
|
||||||
|
"remote_url": None,
|
||||||
|
"meta": {
|
||||||
|
"original": {
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"size": f"{self.width}x{self.height}",
|
||||||
|
"aspect": self.width / self.height,
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"x": self.focal_x or 0,
|
||||||
|
"y": self.focal_y or 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": self.name,
|
||||||
|
"blurhash": self.blurhash,
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
|
||||||
|
def identity_required(function):
|
||||||
|
"""
|
||||||
|
API version of the identity_required decorator that just makes sure the
|
||||||
|
token is tied to one, not an app only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def inner(request, *args, **kwargs):
|
||||||
|
# They need an identity
|
||||||
|
if not request.identity:
|
||||||
|
return JsonResponse({"error": "identity_token_required"}, status=400)
|
||||||
|
return function(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return inner
|
|
@ -0,0 +1,27 @@
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
from api.models import Token
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenMiddleware:
|
||||||
|
"""
|
||||||
|
Adds request.user and request.identity if an API token appears.
|
||||||
|
Also nukes request.session so it can't be used accidentally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
auth_header = request.headers.get("authorization", None)
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
token_value = auth_header[7:]
|
||||||
|
try:
|
||||||
|
token = Token.objects.get(token=token_value)
|
||||||
|
except Token.DoesNotExist:
|
||||||
|
return HttpResponse("Invalid Bearer token", status=400)
|
||||||
|
request.user = token.user
|
||||||
|
request.identity = token.identity
|
||||||
|
request.session = None
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
|
@ -0,0 +1,108 @@
|
||||||
|
from typing import Literal, Optional, Union
|
||||||
|
|
||||||
|
from ninja import Field, Schema
|
||||||
|
|
||||||
|
|
||||||
|
class Application(Schema):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
website: str | None
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
redirect_uri: str = Field(alias="redirect_uris")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomEmoji(Schema):
|
||||||
|
shortcode: str
|
||||||
|
url: str
|
||||||
|
static_url: str
|
||||||
|
visible_in_picker: bool
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class AccountField(Schema):
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
verified_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Schema):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
acct: str
|
||||||
|
url: str
|
||||||
|
display_name: str
|
||||||
|
note: str
|
||||||
|
avatar: str
|
||||||
|
avatar_static: str
|
||||||
|
header: str
|
||||||
|
header_static: str
|
||||||
|
locked: bool
|
||||||
|
fields: list[AccountField]
|
||||||
|
emojis: list[CustomEmoji]
|
||||||
|
bot: bool
|
||||||
|
group: bool
|
||||||
|
discoverable: bool
|
||||||
|
moved: Union[None, bool, "Account"]
|
||||||
|
suspended: bool
|
||||||
|
limited: bool
|
||||||
|
created_at: str
|
||||||
|
last_status_at: str | None = Field(...)
|
||||||
|
statuses_count: int
|
||||||
|
followers_count: int
|
||||||
|
following_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAttachment(Schema):
|
||||||
|
id: str
|
||||||
|
type: Literal["unknown", "image", "gifv", "video", "audio"]
|
||||||
|
url: str
|
||||||
|
preview_url: str
|
||||||
|
remote_url: str | None
|
||||||
|
meta: dict
|
||||||
|
description: str | None
|
||||||
|
blurhash: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusMention(Schema):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
url: str
|
||||||
|
acct: str
|
||||||
|
|
||||||
|
|
||||||
|
class StatusTag(Schema):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class Status(Schema):
|
||||||
|
id: str
|
||||||
|
uri: str
|
||||||
|
created_at: str
|
||||||
|
account: Account
|
||||||
|
content: str
|
||||||
|
visibility: Literal["public", "unlisted", "private", "direct"]
|
||||||
|
sensitive: bool
|
||||||
|
spoiler_text: str
|
||||||
|
media_attachments: list[MediaAttachment]
|
||||||
|
mentions: list[StatusMention]
|
||||||
|
tags: list[StatusTag]
|
||||||
|
emojis: list[CustomEmoji]
|
||||||
|
reblogs_count: int
|
||||||
|
favourites_count: int
|
||||||
|
replies_count: int
|
||||||
|
url: str | None = Field(...)
|
||||||
|
in_reply_to_id: str | None = Field(...)
|
||||||
|
in_reply_to_account_id: str | None = Field(...)
|
||||||
|
reblog: Optional["Status"] = Field(...)
|
||||||
|
poll: None = Field(...)
|
||||||
|
card: None = Field(...)
|
||||||
|
language: None = Field(...)
|
||||||
|
text: str | None = Field(...)
|
||||||
|
edited_at: str | None
|
||||||
|
favourited: bool | None
|
||||||
|
reblogged: bool | None
|
||||||
|
muted: bool | None
|
||||||
|
bookmarked: bool | None
|
||||||
|
pinned: bool | None
|
|
@ -1,3 +1,6 @@
|
||||||
|
from .accounts import * # noqa
|
||||||
from .apps import * # noqa
|
from .apps import * # noqa
|
||||||
from .base import api # noqa
|
from .base import api # noqa
|
||||||
from .instance import * # noqa
|
from .instance import * # noqa
|
||||||
|
from .oauth import * # noqa
|
||||||
|
from .timelines import * # noqa
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
from .. import schemas
|
||||||
|
from ..decorators import identity_required
|
||||||
|
from .base import api
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/v1/accounts/verify_credentials", response=schemas.Account)
|
||||||
|
@identity_required
|
||||||
|
def verify_credentials(request):
|
||||||
|
return request.identity.to_mastodon_json()
|
|
@ -1,7 +1,8 @@
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from ninja import Field, Schema
|
from ninja import Schema
|
||||||
|
|
||||||
|
from .. import schemas
|
||||||
from ..models import Application
|
from ..models import Application
|
||||||
from .base import api
|
from .base import api
|
||||||
|
|
||||||
|
@ -13,16 +14,7 @@ class CreateApplicationSchema(Schema):
|
||||||
website: None | str = None
|
website: None | str = None
|
||||||
|
|
||||||
|
|
||||||
class ApplicationSchema(Schema):
|
@api.post("/v1/apps", response=schemas.Application)
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
website: str | None
|
|
||||||
client_id: str
|
|
||||||
client_secret: str
|
|
||||||
redirect_uri: str = Field(alias="redirect_uris")
|
|
||||||
|
|
||||||
|
|
||||||
@api.post("/v1/apps", response=ApplicationSchema)
|
|
||||||
def add_app(request, details: CreateApplicationSchema):
|
def add_app(request, details: CreateApplicationSchema):
|
||||||
client_id = "tk-" + secrets.token_urlsafe(16)
|
client_id = "tk-" + secrets.token_urlsafe(16)
|
||||||
client_secret = secrets.token_urlsafe(40)
|
client_secret = secrets.token_urlsafe(40)
|
||||||
|
|
|
@ -9,7 +9,6 @@ from .base import api
|
||||||
|
|
||||||
|
|
||||||
@api.get("/v1/instance")
|
@api.get("/v1/instance")
|
||||||
@api.get("/v1/instance/")
|
|
||||||
def instance_info(request):
|
def instance_info(request):
|
||||||
return {
|
return {
|
||||||
"uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN),
|
"uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN),
|
||||||
|
|
|
@ -66,7 +66,6 @@ class AuthorizationView(LoginRequiredMixin, TemplateView):
|
||||||
class TokenView(View):
|
class TokenView(View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
grant_type = request.POST["grant_type"]
|
grant_type = request.POST["grant_type"]
|
||||||
scopes = set(self.request.POST.get("scope", "read").split())
|
|
||||||
try:
|
try:
|
||||||
application = Application.objects.get(
|
application = Application.objects.get(
|
||||||
client_id=self.request.POST["client_id"]
|
client_id=self.request.POST["client_id"]
|
||||||
|
@ -84,9 +83,6 @@ class TokenView(View):
|
||||||
token = Token.objects.get(code=code, application=application)
|
token = Token.objects.get(code=code, application=application)
|
||||||
except Token.DoesNotExist:
|
except Token.DoesNotExist:
|
||||||
return JsonResponse({"error": "invalid_code"}, status=400)
|
return JsonResponse({"error": "invalid_code"}, status=400)
|
||||||
# Verify the scopes match the token
|
|
||||||
if scopes != set(token.scopes):
|
|
||||||
return JsonResponse({"error": "invalid_scope"}, status=400)
|
|
||||||
# Update the token to remove its code
|
# Update the token to remove its code
|
||||||
token.code = None
|
token.code = None
|
||||||
token.save()
|
token.save()
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
from activities.models import TimelineEvent
|
||||||
|
|
||||||
|
from .. import schemas
|
||||||
|
from ..decorators import identity_required
|
||||||
|
from .base import api
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/v1/timelines/home", response=list[schemas.Status])
|
||||||
|
@identity_required
|
||||||
|
def home(request):
|
||||||
|
if request.GET.get("max_id"):
|
||||||
|
return []
|
||||||
|
limit = int(request.GET.get("limit", "20"))
|
||||||
|
events = (
|
||||||
|
TimelineEvent.objects.filter(
|
||||||
|
identity=request.identity,
|
||||||
|
type__in=[TimelineEvent.Types.post],
|
||||||
|
)
|
||||||
|
.select_related("subject_post", "subject_post__author")
|
||||||
|
.prefetch_related("subject_post__attachments")
|
||||||
|
.order_by("-created")[:limit]
|
||||||
|
)
|
||||||
|
return [event.subject_post.to_mastodon_json() for event in events]
|
|
@ -192,6 +192,7 @@ MIDDLEWARE = [
|
||||||
"django_htmx.middleware.HtmxMiddleware",
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
"core.middleware.AcceptMiddleware",
|
"core.middleware.AcceptMiddleware",
|
||||||
"core.middleware.ConfigLoadingMiddleware",
|
"core.middleware.ConfigLoadingMiddleware",
|
||||||
|
"api.middleware.ApiTokenMiddleware",
|
||||||
"users.middleware.IdentityMiddleware",
|
"users.middleware.IdentityMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_verify_credentials(api_token, identity, client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/accounts/verify_credentials",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {api_token.token}",
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
).json()
|
||||||
|
assert response["id"] == str(identity.pk)
|
||||||
|
assert response["username"] == identity.username
|
|
@ -0,0 +1,11 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_instance(api_token, client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/instance",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {api_token.token}",
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
).json()
|
||||||
|
assert response["uri"] == "example.com"
|
|
@ -2,6 +2,7 @@ import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from api.models import Application, Token
|
||||||
from core.models import Config
|
from core.models import Config
|
||||||
from stator.runner import StatorModel, StatorRunner
|
from stator.runner import StatorModel, StatorRunner
|
||||||
from users.models import Domain, Identity, User
|
from users.models import Domain, Identity, User
|
||||||
|
@ -171,6 +172,26 @@ def remote_identity2() -> Identity:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def api_token(identity) -> Token:
|
||||||
|
"""
|
||||||
|
Creates an API application, an identity, and a token for that identity
|
||||||
|
"""
|
||||||
|
application = Application.objects.create(
|
||||||
|
name="Test App",
|
||||||
|
client_id="tk-test",
|
||||||
|
client_secret="mytestappsecret",
|
||||||
|
)
|
||||||
|
return Token.objects.create(
|
||||||
|
application=application,
|
||||||
|
user=identity.users.first(),
|
||||||
|
identity=identity,
|
||||||
|
token="mytestapitoken",
|
||||||
|
scopes=["read", "write", "follow", "push"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def stator(config_system) -> StatorRunner:
|
def stator(config_system) -> StatorRunner:
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -13,15 +13,21 @@ class IdentityMiddleware:
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
identity_id = request.session.get("identity_id")
|
# The API middleware might have set identity already
|
||||||
if not identity_id:
|
if not hasattr(request, "identity"):
|
||||||
request.identity = None
|
# See if we have one in the session
|
||||||
else:
|
identity_id = request.session.get("identity_id")
|
||||||
try:
|
if not identity_id:
|
||||||
request.identity = Identity.objects.get(id=identity_id)
|
|
||||||
User.objects.filter(pk=request.user.pk).update(last_seen=timezone.now())
|
|
||||||
except Identity.DoesNotExist:
|
|
||||||
request.identity = None
|
request.identity = None
|
||||||
|
else:
|
||||||
|
# Pull it out of the DB and assign it
|
||||||
|
try:
|
||||||
|
request.identity = Identity.objects.get(id=identity_id)
|
||||||
|
User.objects.filter(pk=request.user.pk).update(
|
||||||
|
last_seen=timezone.now()
|
||||||
|
)
|
||||||
|
except Identity.DoesNotExist:
|
||||||
|
request.identity = None
|
||||||
|
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
return response
|
return response
|
||||||
|
|
|
@ -5,6 +5,7 @@ from urllib.parse import urlparse
|
||||||
import httpx
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
from asgiref.sync import async_to_sync, sync_to_async
|
from asgiref.sync import async_to_sync, sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
from django.db import IntegrityError, models
|
from django.db import IntegrityError, models
|
||||||
from django.template.defaultfilters import linebreaks_filter
|
from django.template.defaultfilters import linebreaks_filter
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
|
@ -13,7 +14,7 @@ from django.utils.functional import lazy
|
||||||
|
|
||||||
from core.exceptions import ActorMismatchError
|
from core.exceptions import ActorMismatchError
|
||||||
from core.html import sanitize_post, strip_html
|
from core.html import sanitize_post, strip_html
|
||||||
from core.ld import canonicalise, get_list, media_type_from_filename
|
from core.ld import canonicalise, format_ld_date, get_list, media_type_from_filename
|
||||||
from core.models import Config
|
from core.models import Config
|
||||||
from core.signatures import HttpSignature, RsaKeys
|
from core.signatures import HttpSignature, RsaKeys
|
||||||
from core.uploads import upload_namer
|
from core.uploads import upload_namer
|
||||||
|
@ -153,7 +154,7 @@ class Identity(StatorModel):
|
||||||
if self.icon:
|
if self.icon:
|
||||||
return self.icon.url
|
return self.icon.url
|
||||||
elif self.icon_uri:
|
elif self.icon_uri:
|
||||||
return f"/proxy/identity_icon/{self.pk}/"
|
return f"https://{settings.MAIN_DOMAIN}/proxy/identity_icon/{self.pk}/"
|
||||||
else:
|
else:
|
||||||
return static("img/unknown-icon-128.png")
|
return static("img/unknown-icon-128.png")
|
||||||
|
|
||||||
|
@ -164,7 +165,7 @@ class Identity(StatorModel):
|
||||||
if self.image:
|
if self.image:
|
||||||
return self.image.url
|
return self.image.url
|
||||||
elif self.image_uri:
|
elif self.image_uri:
|
||||||
return f"/proxy/identity_image/{self.pk}/"
|
return f"https://{settings.MAIN_DOMAIN}/proxy/identity_image/{self.pk}/"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def safe_summary(self):
|
def safe_summary(self):
|
||||||
|
@ -466,6 +467,44 @@ class Identity(StatorModel):
|
||||||
await sync_to_async(self.save)()
|
await sync_to_async(self.save)()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
### Mastodon Client API ###
|
||||||
|
|
||||||
|
def to_mastodon_json(self):
|
||||||
|
return {
|
||||||
|
"id": self.pk,
|
||||||
|
"username": self.username,
|
||||||
|
"acct": self.username if self.local else self.handle,
|
||||||
|
"url": self.absolute_profile_uri(),
|
||||||
|
"display_name": self.name,
|
||||||
|
"note": self.summary or "",
|
||||||
|
"avatar": self.local_icon_url(),
|
||||||
|
"avatar_static": self.local_icon_url(),
|
||||||
|
"header": self.local_image_url() or "",
|
||||||
|
"header_static": self.local_image_url() or "",
|
||||||
|
"locked": False,
|
||||||
|
"fields": (
|
||||||
|
[
|
||||||
|
{"name": m["name"], "value": m["value"], "verified_at": None}
|
||||||
|
for m in self.metadata
|
||||||
|
]
|
||||||
|
if self.metadata
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
"emojis": [],
|
||||||
|
"bot": False,
|
||||||
|
"group": False,
|
||||||
|
"discoverable": self.discoverable,
|
||||||
|
"suspended": False,
|
||||||
|
"limited": False,
|
||||||
|
"created_at": format_ld_date(
|
||||||
|
self.created.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
),
|
||||||
|
"last_status_at": None, # TODO: populate
|
||||||
|
"statuses_count": self.posts.count(),
|
||||||
|
"followers_count": self.inbound_follows.count(),
|
||||||
|
"following_count": self.outbound_follows.count(),
|
||||||
|
}
|
||||||
|
|
||||||
### Cryptography ###
|
### Cryptography ###
|
||||||
|
|
||||||
async def signed_request(
|
async def signed_request(
|
||||||
|
|
Loading…
Reference in New Issue