This commit is contained in:
Dan Watson 2024-04-19 23:14:53 -04:00
parent 9759a97463
commit ab86b1cbee
11 changed files with 300 additions and 12 deletions

View file

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

View file

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

View file

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

View file

@ -44,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),
@ -67,7 +68,29 @@ 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 # Markers
path( path(
"v1/markers", "v1/markers",
@ -134,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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ from users.models import (
Identity, Identity,
InboxMessage, InboxMessage,
Invite, Invite,
List,
Marker, Marker,
PasswordReset, PasswordReset,
Report, Report,
@ -213,6 +214,12 @@ 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) @admin.register(Marker)
class MarkerAdmin(admin.ModelAdmin): class MarkerAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"] list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"]

View file

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

View file

@ -7,6 +7,7 @@ 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 .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

37
users/models/lists.py Normal file
View file

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