kopia lustrzana https://github.com/jointakahe/takahe
Merge 5035b39a6c
into 7c34ac78ed
commit
9e2a09ef41
|
@ -5,6 +5,7 @@
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/*
|
.idea/*
|
||||||
|
.nova
|
||||||
.venv
|
.venv
|
||||||
.vscode
|
.vscode
|
||||||
/*.env*
|
/*.env*
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.db.models import OuterRef
|
||||||
|
|
||||||
from activities.models import (
|
from activities.models import (
|
||||||
Post,
|
Post,
|
||||||
PostInteraction,
|
PostInteraction,
|
||||||
|
@ -18,11 +20,11 @@ class PostService:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def queryset(cls):
|
def queryset(cls, include_reply_to_author=False):
|
||||||
"""
|
"""
|
||||||
Returns the base queryset to use for fetching posts efficiently.
|
Returns the base queryset to use for fetching posts efficiently.
|
||||||
"""
|
"""
|
||||||
return (
|
qs = (
|
||||||
Post.objects.not_hidden()
|
Post.objects.not_hidden()
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"attachments",
|
"attachments",
|
||||||
|
@ -34,6 +36,13 @@ class PostService:
|
||||||
"author__domain",
|
"author__domain",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if include_reply_to_author:
|
||||||
|
qs = qs.annotate(
|
||||||
|
in_reply_to_author_id=Post.objects.filter(
|
||||||
|
object_uri=OuterRef("in_reply_to")
|
||||||
|
).values("author_id")[:1]
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
def __init__(self, post: Post):
|
def __init__(self, post: Post):
|
||||||
self.post = post
|
self.post = post
|
||||||
|
|
|
@ -8,7 +8,8 @@ from activities.models import (
|
||||||
TimelineEvent,
|
TimelineEvent,
|
||||||
)
|
)
|
||||||
from activities.services import PostService
|
from activities.services import PostService
|
||||||
from users.models import Identity
|
from users.models import Identity, List
|
||||||
|
from users.services import IdentityService
|
||||||
|
|
||||||
|
|
||||||
class TimelineService:
|
class TimelineService:
|
||||||
|
@ -152,3 +153,30 @@ class TimelineService:
|
||||||
.filter(bookmarks__identity=self.identity)
|
.filter(bookmarks__identity=self.identity)
|
||||||
.order_by("-id")
|
.order_by("-id")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def for_list(self, alist: List) -> models.QuerySet[Post]:
|
||||||
|
"""
|
||||||
|
Return posts from members of `alist`, filtered by the lists replies policy.
|
||||||
|
"""
|
||||||
|
assert self.identity # Appease mypy
|
||||||
|
# We only need to include this if we need to filter on it.
|
||||||
|
include_author = alist.replies_policy == "followed"
|
||||||
|
members = alist.members.all()
|
||||||
|
queryset = PostService.queryset(include_reply_to_author=include_author)
|
||||||
|
match alist.replies_policy:
|
||||||
|
case "list":
|
||||||
|
# The default is to show posts (and replies) from list members.
|
||||||
|
criteria = models.Q(author__in=members)
|
||||||
|
case "none":
|
||||||
|
# Don't show any replies, just original posts from list members.
|
||||||
|
criteria = models.Q(author__in=members) & models.Q(
|
||||||
|
in_reply_to__isnull=True
|
||||||
|
)
|
||||||
|
case "followed":
|
||||||
|
# Show posts from list members OR from accounts you follow replying to
|
||||||
|
# posts by list members.
|
||||||
|
criteria = models.Q(author__in=members) | (
|
||||||
|
models.Q(author__in=IdentityService(self.identity).following())
|
||||||
|
& models.Q(in_reply_to_author_id__in=members)
|
||||||
|
)
|
||||||
|
return queryset.filter(criteria).order_by("-id")
|
||||||
|
|
|
@ -407,11 +407,15 @@ class Announcement(Schema):
|
||||||
class List(Schema):
|
class List(Schema):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
replies_policy: Literal[
|
replies_policy: Literal["followed", "list", "none"]
|
||||||
"followed",
|
exclusive: bool
|
||||||
"list",
|
|
||||||
"none",
|
@classmethod
|
||||||
]
|
def from_list(
|
||||||
|
cls,
|
||||||
|
list_instance: users_models.List,
|
||||||
|
) -> "List":
|
||||||
|
return cls(**list_instance.to_mastodon_json())
|
||||||
|
|
||||||
|
|
||||||
class Preferences(Schema):
|
class Preferences(Schema):
|
||||||
|
@ -503,3 +507,16 @@ class PushSubscription(Schema):
|
||||||
return value
|
return value
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Marker(Schema):
|
||||||
|
last_read_id: str
|
||||||
|
version: int
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_marker(
|
||||||
|
cls,
|
||||||
|
marker: users_models.Marker,
|
||||||
|
) -> "Marker":
|
||||||
|
return cls(**marker.to_mastodon_json())
|
||||||
|
|
35
api/urls.py
35
api/urls.py
|
@ -11,6 +11,7 @@ from api.views import (
|
||||||
follow_requests,
|
follow_requests,
|
||||||
instance,
|
instance,
|
||||||
lists,
|
lists,
|
||||||
|
markers,
|
||||||
media,
|
media,
|
||||||
notifications,
|
notifications,
|
||||||
polls,
|
polls,
|
||||||
|
@ -43,6 +44,7 @@ urlpatterns = [
|
||||||
path("v1/accounts/<id>/following", accounts.account_following),
|
path("v1/accounts/<id>/following", accounts.account_following),
|
||||||
path("v1/accounts/<id>/followers", accounts.account_followers),
|
path("v1/accounts/<id>/followers", accounts.account_followers),
|
||||||
path("v1/accounts/<id>/featured_tags", accounts.account_featured_tags),
|
path("v1/accounts/<id>/featured_tags", accounts.account_featured_tags),
|
||||||
|
path("v1/accounts/<id>/lists", accounts.account_lists),
|
||||||
# Announcements
|
# Announcements
|
||||||
path("v1/announcements", announcements.announcement_list),
|
path("v1/announcements", announcements.announcement_list),
|
||||||
path("v1/announcements/<pk>/dismiss", announcements.announcement_dismiss),
|
path("v1/announcements/<pk>/dismiss", announcements.announcement_dismiss),
|
||||||
|
@ -66,7 +68,37 @@ urlpatterns = [
|
||||||
path("v1/instance/peers", instance.peers),
|
path("v1/instance/peers", instance.peers),
|
||||||
path("v2/instance", instance.instance_info_v2),
|
path("v2/instance", instance.instance_info_v2),
|
||||||
# Lists
|
# Lists
|
||||||
path("v1/lists", lists.get_lists),
|
path(
|
||||||
|
"v1/lists",
|
||||||
|
methods(
|
||||||
|
get=lists.get_lists,
|
||||||
|
post=lists.create_list,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"v1/lists/<id>",
|
||||||
|
methods(
|
||||||
|
get=lists.get_list,
|
||||||
|
put=lists.update_list,
|
||||||
|
delete=lists.delete_list,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"v1/lists/<id>/accounts",
|
||||||
|
methods(
|
||||||
|
get=lists.get_accounts,
|
||||||
|
post=lists.add_accounts,
|
||||||
|
delete=lists.delete_accounts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Markers
|
||||||
|
path(
|
||||||
|
"v1/markers",
|
||||||
|
methods(
|
||||||
|
get=markers.markers,
|
||||||
|
post=markers.set_markers,
|
||||||
|
),
|
||||||
|
),
|
||||||
# Media
|
# Media
|
||||||
path("v1/media", media.upload_media),
|
path("v1/media", media.upload_media),
|
||||||
path("v2/media", media.upload_media),
|
path("v2/media", media.upload_media),
|
||||||
|
@ -125,6 +157,7 @@ urlpatterns = [
|
||||||
path("v1/timelines/home", timelines.home),
|
path("v1/timelines/home", timelines.home),
|
||||||
path("v1/timelines/public", timelines.public),
|
path("v1/timelines/public", timelines.public),
|
||||||
path("v1/timelines/tag/<hashtag>", timelines.hashtag),
|
path("v1/timelines/tag/<hashtag>", timelines.hashtag),
|
||||||
|
path("v1/timelines/list/<list_id>", timelines.list_timeline),
|
||||||
path("v1/conversations", timelines.conversations),
|
path("v1/conversations", timelines.conversations),
|
||||||
path("v1/favourites", timelines.favourites),
|
path("v1/favourites", timelines.favourites),
|
||||||
# Trends
|
# Trends
|
||||||
|
|
|
@ -373,3 +373,15 @@ def account_followers(
|
||||||
def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]:
|
def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]:
|
||||||
# Not implemented yet
|
# Not implemented yet
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("read:lists")
|
||||||
|
@api_view.get
|
||||||
|
def account_lists(request: HttpRequest, id: str) -> list[schemas.List]:
|
||||||
|
identity = get_object_or_404(
|
||||||
|
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
schemas.List.from_list(lst)
|
||||||
|
for lst in request.identity.lists.filter(members=identity)
|
||||||
|
]
|
||||||
|
|
|
@ -1,12 +1,95 @@
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from hatchway import api_view
|
from django.shortcuts import get_object_or_404
|
||||||
|
from hatchway import Schema, api_view
|
||||||
|
|
||||||
from api import schemas
|
from api import schemas
|
||||||
from api.decorators import scope_required
|
from api.decorators import scope_required
|
||||||
|
|
||||||
|
|
||||||
|
class CreateList(Schema):
|
||||||
|
title: str
|
||||||
|
replies_policy: Literal["followed", "list", "none"] = "list"
|
||||||
|
exclusive: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateList(Schema):
|
||||||
|
title: str | None
|
||||||
|
replies_policy: Literal["followed", "list", "none"] | None
|
||||||
|
exclusive: bool | None
|
||||||
|
|
||||||
|
|
||||||
@scope_required("read:lists")
|
@scope_required("read:lists")
|
||||||
@api_view.get
|
@api_view.get
|
||||||
def get_lists(request: HttpRequest) -> list[schemas.List]:
|
def get_lists(request: HttpRequest) -> list[schemas.List]:
|
||||||
# We don't implement this yet
|
return [schemas.List.from_list(lst) for lst in request.identity.lists.all()]
|
||||||
return []
|
|
||||||
|
|
||||||
|
@scope_required("write:lists")
|
||||||
|
@api_view.post
|
||||||
|
def create_list(request: HttpRequest, data: CreateList) -> schemas.List:
|
||||||
|
created = request.identity.lists.create(
|
||||||
|
title=data.title,
|
||||||
|
replies_policy=data.replies_policy,
|
||||||
|
exclusive=data.exclusive,
|
||||||
|
)
|
||||||
|
return schemas.List.from_list(created)
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("read:lists")
|
||||||
|
@api_view.get
|
||||||
|
def get_list(request: HttpRequest, id: str) -> schemas.List:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=id)
|
||||||
|
return schemas.List.from_list(alist)
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("write:lists")
|
||||||
|
@api_view.put
|
||||||
|
def update_list(request: HttpRequest, id: str, data: UpdateList) -> schemas.List:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=id)
|
||||||
|
if data.title:
|
||||||
|
alist.title = data.title
|
||||||
|
if data.replies_policy:
|
||||||
|
alist.replies_policy = data.replies_policy
|
||||||
|
if data.exclusive is not None:
|
||||||
|
alist.exclusive = data.exclusive
|
||||||
|
alist.save()
|
||||||
|
return schemas.List.from_list(alist)
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("write:lists")
|
||||||
|
@api_view.delete
|
||||||
|
def delete_list(request: HttpRequest, id: str) -> dict:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=id)
|
||||||
|
alist.delete()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("write:lists")
|
||||||
|
@api_view.get
|
||||||
|
def get_accounts(request: HttpRequest, id: str) -> list[schemas.Account]:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=id)
|
||||||
|
return [schemas.Account.from_identity(ident) for ident in alist.members.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("write:lists")
|
||||||
|
@api_view.post
|
||||||
|
def add_accounts(request: HttpRequest, id: str) -> dict:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=id)
|
||||||
|
add_ids = request.PARAMS.get("account_ids")
|
||||||
|
for follow in request.identity.outbound_follows.filter(
|
||||||
|
target__id__in=add_ids
|
||||||
|
).select_related("target"):
|
||||||
|
alist.members.add(follow.target)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("write:lists")
|
||||||
|
@api_view.delete
|
||||||
|
def delete_accounts(request: HttpRequest, id: str) -> dict:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=id)
|
||||||
|
remove_ids = request.PARAMS.get("account_ids")
|
||||||
|
for ident in alist.members.filter(id__in=remove_ids):
|
||||||
|
alist.members.remove(ident)
|
||||||
|
return {}
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from hatchway import api_view
|
||||||
|
|
||||||
|
from api import schemas
|
||||||
|
from api.decorators import scope_required
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("read:statuses")
|
||||||
|
@api_view.get
|
||||||
|
def markers(request: HttpRequest) -> dict[str, schemas.Marker]:
|
||||||
|
timelines = set(request.PARAMS.getlist("timeline[]"))
|
||||||
|
data = {}
|
||||||
|
for m in request.identity.markers.filter(timeline__in=timelines):
|
||||||
|
data[m.timeline] = schemas.Marker.from_marker(m)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("write:statuses")
|
||||||
|
@api_view.post
|
||||||
|
def set_markers(request: HttpRequest) -> dict[str, schemas.Marker]:
|
||||||
|
markers = {}
|
||||||
|
for key, last_id in request.PARAMS.items():
|
||||||
|
if not key.endswith("[last_read_id]"):
|
||||||
|
continue
|
||||||
|
timeline = key.replace("[last_read_id]", "")
|
||||||
|
marker, created = request.identity.markers.get_or_create(
|
||||||
|
timeline=timeline,
|
||||||
|
defaults={
|
||||||
|
"last_read_id": last_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
marker.last_read_id = last_id
|
||||||
|
marker.save()
|
||||||
|
markers[timeline] = schemas.Marker.from_marker(marker)
|
||||||
|
return markers
|
|
@ -1,5 +1,4 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
@ -41,19 +40,6 @@ class OauthRedirect(HttpResponseRedirect):
|
||||||
super().__init__(urlunparse(url_parts))
|
super().__init__(urlunparse(url_parts))
|
||||||
|
|
||||||
|
|
||||||
def get_json_and_formdata(request):
|
|
||||||
# Did they submit JSON?
|
|
||||||
if request.content_type == "application/json" and request.body.strip():
|
|
||||||
return json.loads(request.body)
|
|
||||||
# Fall back to form data
|
|
||||||
value = {}
|
|
||||||
for key, item in request.POST.items():
|
|
||||||
value[key] = item
|
|
||||||
for key, item in request.GET.items():
|
|
||||||
value[key] = item
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationView(LoginRequiredMixin, View):
|
class AuthorizationView(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Asks the user to authorize access.
|
Asks the user to authorize access.
|
||||||
|
@ -106,7 +92,7 @@ class AuthorizationView(LoginRequiredMixin, View):
|
||||||
return render(request, "api/oauth_authorize.html", context)
|
return render(request, "api/oauth_authorize.html", context)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
post_data = get_json_and_formdata(request)
|
post_data = request.PARAMS
|
||||||
# Grab the application and other details again
|
# Grab the application and other details again
|
||||||
redirect_uri = post_data["redirect_uri"]
|
redirect_uri = post_data["redirect_uri"]
|
||||||
scope = post_data["scope"]
|
scope = post_data["scope"]
|
||||||
|
@ -160,7 +146,7 @@ class TokenView(View):
|
||||||
)
|
)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
post_data = get_json_and_formdata(request)
|
post_data = request.PARAMS.copy()
|
||||||
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
|
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
|
@ -243,7 +229,7 @@ class TokenView(View):
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
class RevokeTokenView(View):
|
class RevokeTokenView(View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
post_data = get_json_and_formdata(request)
|
post_data = request.PARAMS.copy()
|
||||||
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
|
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from hatchway import ApiError, ApiResponse, api_view
|
from hatchway import ApiError, ApiResponse, api_view
|
||||||
|
|
||||||
from activities.models import Post, TimelineEvent
|
from activities.models import Post, TimelineEvent
|
||||||
|
@ -159,3 +160,31 @@ def favourites(
|
||||||
request=request,
|
request=request,
|
||||||
include_params=["limit"],
|
include_params=["limit"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@scope_required("read:lists")
|
||||||
|
@api_view.get
|
||||||
|
def list_timeline(
|
||||||
|
request: HttpRequest,
|
||||||
|
list_id: str,
|
||||||
|
max_id: str | None = None,
|
||||||
|
since_id: str | None = None,
|
||||||
|
min_id: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> ApiResponse[list[schemas.Status]]:
|
||||||
|
alist = get_object_or_404(request.identity.lists, pk=list_id)
|
||||||
|
queryset = TimelineService(request.identity).for_list(alist)
|
||||||
|
|
||||||
|
paginator = MastodonPaginator()
|
||||||
|
pager: PaginationResult[Post] = paginator.paginate(
|
||||||
|
queryset,
|
||||||
|
min_id=min_id,
|
||||||
|
max_id=max_id,
|
||||||
|
since_id=since_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return PaginatingApiResponse(
|
||||||
|
schemas.Status.map_from_post(pager.results, request.identity),
|
||||||
|
request=request,
|
||||||
|
include_params=["limit"],
|
||||||
|
)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -73,3 +74,25 @@ def show_toolbar(request):
|
||||||
Determines whether to show the debug toolbar on a given page.
|
Determines whether to show the debug toolbar on a given page.
|
||||||
"""
|
"""
|
||||||
return settings.DEBUG and request.user.is_authenticated and request.user.admin
|
return settings.DEBUG and request.user.is_authenticated and request.user.admin
|
||||||
|
|
||||||
|
|
||||||
|
class ParamsMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def make_params(self, request):
|
||||||
|
# See https://docs.joinmastodon.org/client/intro/#parameters
|
||||||
|
# If they sent JSON, use that.
|
||||||
|
if request.content_type == "application/json" and request.body.strip():
|
||||||
|
return json.loads(request.body)
|
||||||
|
# Otherwise, fall back to form data.
|
||||||
|
params = {}
|
||||||
|
for key, value in request.GET.items():
|
||||||
|
params[key] = value
|
||||||
|
for key, value in request.POST.items():
|
||||||
|
params[key] = value
|
||||||
|
return params
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
request.PARAMS = self.make_params(request)
|
||||||
|
return self.get_response(request)
|
||||||
|
|
|
@ -9,6 +9,7 @@ Currently, it supports:
|
||||||
* A web UI (which can be installed as a PWA as well)
|
* A web UI (which can be installed as a PWA as well)
|
||||||
* Mastodon-compatible client applications (beta support)
|
* Mastodon-compatible client applications (beta support)
|
||||||
* Posts with content warnings and visibilities including a local-only option
|
* Posts with content warnings and visibilities including a local-only option
|
||||||
|
* Creating polls on posts
|
||||||
* Editing post content
|
* Editing post content
|
||||||
* Viewing images, videos and other post attachments
|
* Viewing images, videos and other post attachments
|
||||||
* Uploading images and attaching image captions
|
* Uploading images and attaching image captions
|
||||||
|
@ -28,6 +29,8 @@ Currently, it supports:
|
||||||
* Server defederation (blocking)
|
* Server defederation (blocking)
|
||||||
* Signup flow, including auto-cap by user numbers and invite system
|
* Signup flow, including auto-cap by user numbers and invite system
|
||||||
* Password reset via email
|
* Password reset via email
|
||||||
|
* Bookmarks
|
||||||
|
* Markers
|
||||||
|
|
||||||
Features planned for releases up to 1.0:
|
Features planned for releases up to 1.0:
|
||||||
|
|
||||||
|
@ -41,9 +44,7 @@ Features planned for releases up to 1.0:
|
||||||
|
|
||||||
Features that may make it into 1.0, or might be further out:
|
Features that may make it into 1.0, or might be further out:
|
||||||
|
|
||||||
* Creating polls on posts
|
|
||||||
* Filters
|
* Filters
|
||||||
* Bookmarks
|
|
||||||
* Lists
|
* Lists
|
||||||
* Scheduling posts
|
* Scheduling posts
|
||||||
* Mastodon-compatible account migration target/source
|
* Mastodon-compatible account migration target/source
|
||||||
|
|
|
@ -13,3 +13,17 @@ variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_
|
||||||
Note that users of apps may need to sign out and in again to their accounts for
|
Note that users of apps may need to sign out and in again to their accounts for
|
||||||
the app to notice that it can now do push notifications. Some apps, like Elk,
|
the app to notice that it can now do push notifications. Some apps, like Elk,
|
||||||
may cache the fact your server didn't support it for a while.
|
may cache the fact your server didn't support it for a while.
|
||||||
|
|
||||||
|
|
||||||
|
Marker Support
|
||||||
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Takahē now supports the `Markers API <https://docs.joinmastodon.org/methods/markers/>`_,
|
||||||
|
used by clients to sync read positions within timelines.
|
||||||
|
|
||||||
|
|
||||||
|
Lists Support
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Takahē now supports the `Lists APIs <https://docs.joinmastodon.org/methods/lists/>`_,
|
||||||
|
used by clients to maintain lists of accounts to show timelines for.
|
||||||
|
|
|
@ -233,6 +233,7 @@ MIDDLEWARE = [
|
||||||
"django_htmx.middleware.HtmxMiddleware",
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
"core.middleware.HeadersMiddleware",
|
"core.middleware.HeadersMiddleware",
|
||||||
"core.middleware.ConfigLoadingMiddleware",
|
"core.middleware.ConfigLoadingMiddleware",
|
||||||
|
"core.middleware.ParamsMiddleware",
|
||||||
"api.middleware.ApiTokenMiddleware",
|
"api.middleware.ApiTokenMiddleware",
|
||||||
"users.middleware.DomainMiddleware",
|
"users.middleware.DomainMiddleware",
|
||||||
]
|
]
|
||||||
|
|
|
@ -13,6 +13,8 @@ from users.models import (
|
||||||
Identity,
|
Identity,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
Invite,
|
Invite,
|
||||||
|
List,
|
||||||
|
Marker,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
Report,
|
Report,
|
||||||
User,
|
User,
|
||||||
|
@ -212,6 +214,17 @@ class InviteAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "created", "token", "note"]
|
list_display = ["id", "created", "token", "note"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(List)
|
||||||
|
class ListAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "identity", "title", "replies_policy", "exclusive"]
|
||||||
|
autocomplete_fields = ["members"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Marker)
|
||||||
|
class MarkerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Report)
|
@admin.register(Report)
|
||||||
class ReportAdmin(admin.ModelAdmin):
|
class ReportAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "created", "resolved", "type", "subject_identity"]
|
list_display = ["id", "created", "resolved", "type", "subject_identity"]
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Generated by Django 4.2.11 on 2024-04-17 03:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0022_follow_request"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Marker",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("timeline", models.CharField(max_length=100)),
|
||||||
|
("last_read_id", models.CharField(max_length=200)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"identity",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="markers",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("identity", "timeline")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Generated by Django 4.2.11 on 2024-04-19 01:03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0023_marker"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="List",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
(
|
||||||
|
"replies_policy",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("followed", "Followed"),
|
||||||
|
("list", "List Only"),
|
||||||
|
("none", "None"),
|
||||||
|
],
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("exclusive", models.BooleanField()),
|
||||||
|
(
|
||||||
|
"identity",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="lists",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"members",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True, related_name="in_lists", to="users.identity"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -7,6 +7,8 @@ from .hashtag_follow import HashtagFollow # noqa
|
||||||
from .identity import Identity, IdentityStates # noqa
|
from .identity import Identity, IdentityStates # noqa
|
||||||
from .inbox_message import InboxMessage, InboxMessageStates # noqa
|
from .inbox_message import InboxMessage, InboxMessageStates # noqa
|
||||||
from .invite import Invite # noqa
|
from .invite import Invite # noqa
|
||||||
|
from .lists import List # noqa
|
||||||
|
from .marker import Marker # noqa
|
||||||
from .password_reset import PasswordReset # noqa
|
from .password_reset import PasswordReset # noqa
|
||||||
from .report import Report # noqa
|
from .report import Report # noqa
|
||||||
from .system_actor import SystemActor # noqa
|
from .system_actor import SystemActor # noqa
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class List(models.Model):
|
||||||
|
"""
|
||||||
|
A list of accounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class RepliesPolicy(models.TextChoices):
|
||||||
|
followed = "followed"
|
||||||
|
list_only = "list"
|
||||||
|
none = "none"
|
||||||
|
|
||||||
|
identity = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="lists",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
replies_policy = models.CharField(max_length=10, choices=RepliesPolicy.choices)
|
||||||
|
exclusive = models.BooleanField()
|
||||||
|
members = models.ManyToManyField(
|
||||||
|
"users.Identity",
|
||||||
|
related_name="in_lists",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.id}: {self.identity} → {self.title}"
|
||||||
|
|
||||||
|
def to_mastodon_json(self):
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"title": self.title,
|
||||||
|
"replies_policy": self.replies_policy,
|
||||||
|
"exclusive": self.exclusive,
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.ld import format_ld_date
|
||||||
|
|
||||||
|
|
||||||
|
class Marker(models.Model):
|
||||||
|
"""
|
||||||
|
A timeline marker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
identity = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="markers",
|
||||||
|
)
|
||||||
|
timeline = models.CharField(max_length=100)
|
||||||
|
last_read_id = models.CharField(max_length=200)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("identity", "timeline")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.id}: {self.identity} → {self.timeline}[{self.last_read_id}]"
|
||||||
|
|
||||||
|
def to_mastodon_json(self):
|
||||||
|
return {
|
||||||
|
"last_read_id": self.last_read_id,
|
||||||
|
"version": 0,
|
||||||
|
"updated_at": format_ld_date(self.updated_at),
|
||||||
|
}
|
Ładowanie…
Reference in New Issue