mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-28 18:21:00 +00:00
Lists
This commit is contained in:
parent
9759a97463
commit
ab86b1cbee
11 changed files with 300 additions and 12 deletions
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
|
26
api/urls.py
26
api/urls.py
|
@ -44,6 +44,7 @@ urlpatterns = [
|
|||
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),
|
||||
|
@ -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/<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",
|
||||
|
@ -134,6 +157,7 @@ urlpatterns = [
|
|||
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
|
||||
|
|
|
@ -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)
|
||||
]
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
|
|
54
users/migrations/0024_list.py
Normal file
54
users/migrations/0024_list.py
Normal 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"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
37
users/models/lists.py
Normal file
37
users/models/lists.py
Normal 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,
|
||||
}
|
Loading…
Reference in a new issue