Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement markers and lists #707

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*.sqlite3
.DS_Store
.idea/*
.nova
.venv
.vscode
/*.env*
Expand Down
13 changes: 11 additions & 2 deletions activities/services/post.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging

from django.db.models import OuterRef

from activities.models import (
Post,
PostInteraction,
Expand All @@ -18,11 +20,11 @@ class PostService:
"""

@classmethod
def queryset(cls):
def queryset(cls, include_reply_to_author=False):
"""
Returns the base queryset to use for fetching posts efficiently.
"""
return (
qs = (
Post.objects.not_hidden()
.prefetch_related(
"attachments",
Expand All @@ -34,6 +36,13 @@ def queryset(cls):
"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):
self.post = post
Expand Down
30 changes: 29 additions & 1 deletion activities/services/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
TimelineEvent,
)
from activities.services import PostService
from users.models import Identity
from users.models import Identity, List
from users.services import IdentityService


class TimelineService:
Expand Down Expand Up @@ -152,3 +153,30 @@ def bookmarks(self) -> models.QuerySet[Post]:
.filter(bookmarks__identity=self.identity)
.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")
27 changes: 22 additions & 5 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,11 +407,15 @@ def from_announcement(
class List(Schema):
id: str
title: str
replies_policy: Literal[
"followed",
"list",
"none",
]
replies_policy: Literal["followed", "list", "none"]
exclusive: bool

@classmethod
def from_list(
cls,
list_instance: users_models.List,
) -> "List":
return cls(**list_instance.to_mastodon_json())


class Preferences(Schema):
Expand Down Expand Up @@ -503,3 +507,16 @@ def from_token(
return value
else:
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 changes: 34 additions & 1 deletion api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
follow_requests,
instance,
lists,
markers,
media,
notifications,
polls,
Expand Down Expand Up @@ -43,6 +44,7 @@
path("v1/accounts/<id>/following", accounts.account_following),
path("v1/accounts/<id>/followers", accounts.account_followers),
path("v1/accounts/<id>/featured_tags", accounts.account_featured_tags),
path("v1/accounts/<id>/lists", accounts.account_lists),
# Announcements
path("v1/announcements", announcements.announcement_list),
path("v1/announcements/<pk>/dismiss", announcements.announcement_dismiss),
Expand All @@ -66,7 +68,37 @@
path("v1/instance/peers", instance.peers),
path("v2/instance", instance.instance_info_v2),
# 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
path("v1/media", media.upload_media),
path("v2/media", media.upload_media),
Expand Down Expand Up @@ -125,6 +157,7 @@
path("v1/timelines/home", timelines.home),
path("v1/timelines/public", timelines.public),
path("v1/timelines/tag/<hashtag>", timelines.hashtag),
path("v1/timelines/list/<list_id>", timelines.list_timeline),
path("v1/conversations", timelines.conversations),
path("v1/favourites", timelines.favourites),
# Trends
Expand Down
12 changes: 12 additions & 0 deletions api/views/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,15 @@ def account_followers(
def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]:
# Not implemented yet
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)
]
89 changes: 86 additions & 3 deletions api/views/lists.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,95 @@
from typing import Literal

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.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")
@api_view.get
def get_lists(request: HttpRequest) -> list[schemas.List]:
# We don't implement this yet
return []
return [schemas.List.from_list(lst) for lst in request.identity.lists.all()]


@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 {}
36 changes: 36 additions & 0 deletions api/views/markers.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 3 additions & 17 deletions api/views/oauth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import base64
import json
import secrets
import time
from urllib.parse import urlparse, urlunparse
Expand Down Expand Up @@ -41,19 +40,6 @@ def __init__(self, redirect_uri, **kwargs):
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):
"""
Asks the user to authorize access.
Expand Down Expand Up @@ -106,7 +92,7 @@ def get(self, request):
return render(request, "api/oauth_authorize.html", context)

def post(self, request):
post_data = get_json_and_formdata(request)
post_data = request.PARAMS
# Grab the application and other details again
redirect_uri = post_data["redirect_uri"]
scope = post_data["scope"]
Expand Down Expand Up @@ -160,7 +146,7 @@ def verify_code(
)

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(
request
)
Expand Down Expand Up @@ -243,7 +229,7 @@ def post(self, request):
@method_decorator(csrf_exempt, name="dispatch")
class RevokeTokenView(View):
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(
request
)
Expand Down
Loading
Loading