From ab86b1cbeed88a79ea96938455246dc21336c704 Mon Sep 17 00:00:00 2001 From: Dan Watson Date: Fri, 19 Apr 2024 23:14:53 -0400 Subject: [PATCH] Lists --- activities/services/post.py | 13 ++++- activities/services/timeline.py | 30 ++++++++++- api/schemas.py | 14 ++++-- api/urls.py | 26 +++++++++- api/views/accounts.py | 12 +++++ api/views/lists.py | 89 +++++++++++++++++++++++++++++++-- api/views/timelines.py | 29 +++++++++++ users/admin.py | 7 +++ users/migrations/0024_list.py | 54 ++++++++++++++++++++ users/models/__init__.py | 1 + users/models/lists.py | 37 ++++++++++++++ 11 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 users/migrations/0024_list.py create mode 100644 users/models/lists.py diff --git a/activities/services/post.py b/activities/services/post.py index dbfc837..035498f 100644 --- a/activities/services/post.py +++ b/activities/services/post.py @@ -1,5 +1,7 @@ import logging +from django.db.models import OuterRef + from activities.models import ( Post, PostInteraction, @@ -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", @@ -34,6 +36,13 @@ class PostService: "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 diff --git a/activities/services/timeline.py b/activities/services/timeline.py index 925606a..d73cd02 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -8,7 +8,8 @@ from activities.models import ( TimelineEvent, ) from activities.services import PostService -from users.models import Identity +from users.models import Identity, List +from users.services import IdentityService class TimelineService: @@ -152,3 +153,30 @@ class TimelineService: .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") diff --git a/api/schemas.py b/api/schemas.py index ca6d516..07d95f8 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -407,11 +407,15 @@ class Announcement(Schema): 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): diff --git a/api/urls.py b/api/urls.py index 65667a1..be83e88 100644 --- a/api/urls.py +++ b/api/urls.py @@ -44,6 +44,7 @@ urlpatterns = [ path("v1/accounts//following", accounts.account_following), path("v1/accounts//followers", accounts.account_followers), path("v1/accounts//featured_tags", accounts.account_featured_tags), + path("v1/accounts//lists", accounts.account_lists), # Announcements path("v1/announcements", announcements.announcement_list), path("v1/announcements//dismiss", announcements.announcement_dismiss), @@ -67,7 +68,29 @@ urlpatterns = [ 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/", + methods( + get=lists.get_list, + put=lists.update_list, + delete=lists.delete_list, + ), + ), + path( + "v1/lists//accounts", + methods( + get=lists.get_accounts, + post=lists.add_accounts, + delete=lists.delete_accounts, + ), + ), # Markers path( "v1/markers", @@ -134,6 +157,7 @@ urlpatterns = [ path("v1/timelines/home", timelines.home), path("v1/timelines/public", timelines.public), path("v1/timelines/tag/", timelines.hashtag), + path("v1/timelines/list/", timelines.list_timeline), path("v1/conversations", timelines.conversations), path("v1/favourites", timelines.favourites), # Trends diff --git a/api/views/accounts.py b/api/views/accounts.py index 629051f..44bb4b7 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -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) + ] diff --git a/api/views/lists.py b/api/views/lists.py index 2ff10d2..b32e3d8 100644 --- a/api/views/lists.py +++ b/api/views/lists.py @@ -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 {} diff --git a/api/views/timelines.py b/api/views/timelines.py index 9f4ed5a..6e96d85 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,4 +1,5 @@ from django.http import HttpRequest +from django.shortcuts import get_object_or_404 from hatchway import ApiError, ApiResponse, api_view from activities.models import Post, TimelineEvent @@ -159,3 +160,31 @@ def favourites( request=request, 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"], + ) diff --git a/users/admin.py b/users/admin.py index 6ec0e27..2cd5fee 100644 --- a/users/admin.py +++ b/users/admin.py @@ -13,6 +13,7 @@ from users.models import ( Identity, InboxMessage, Invite, + List, Marker, PasswordReset, Report, @@ -213,6 +214,12 @@ class InviteAdmin(admin.ModelAdmin): 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"] diff --git a/users/migrations/0024_list.py b/users/migrations/0024_list.py new file mode 100644 index 0000000..0a5e0cb --- /dev/null +++ b/users/migrations/0024_list.py @@ -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" + ), + ), + ], + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index c0a91d0..40d1885 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -7,6 +7,7 @@ from .hashtag_follow import HashtagFollow # noqa from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa from .invite import Invite # noqa +from .lists import List # noqa from .marker import Marker # noqa from .password_reset import PasswordReset # noqa from .report import Report # noqa diff --git a/users/models/lists.py b/users/models/lists.py new file mode 100644 index 0000000..2daf25e --- /dev/null +++ b/users/models/lists.py @@ -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, + }