userfollow and favorite listening activities

merge-requests/2774/head
Petitminion 2024-04-08 13:44:57 +02:00
rodzic 4bef27552f
commit 878cb32b96
70 zmienionych plików z 2025 dodań i 167 usunięć

Wyświetl plik

@ -15,6 +15,11 @@ v2_patterns += [
r"^radios/",
include(("funkwhale_api.radios.urls_v2", "radios"), namespace="radios"),
),
# to do : to delete
# re_path(
# r"^users/",
# include(("funkwhale_api.users.api_urls_v2", "users"), namespace="users"),
# ),
]
urlpatterns = [re_path("", include((v2_patterns, "v2"), namespace="v2"))]

Wyświetl plik

@ -37,16 +37,19 @@ def combined_recent(limit, **kwargs):
return records
# to do : this should look into actor privacy_level and not iuser privacy level if we want to handle federated
# privacy_level acces mmanagement
def get_activity(user, limit=20):
query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
query = fields.privacy_level_query(
user, "actor__user__privacy_level", "actor__user"
)
querysets = [
Listening.objects.filter(query).select_related(
"track", "user", "track__artist", "track__album__artist"
"track", "actor", "track__artist", "track__album__artist"
),
TrackFavorite.objects.filter(query).select_related(
"track", "user", "track__artist", "track__album__artist"
"track", "actor", "track__artist", "track__album__artist"
),
]
records = combined_recent(limit=limit, querysets=querysets)
return [r["object"] for r in records]

Wyświetl plik

@ -56,3 +56,40 @@ class OwnerPermission(BasePermission):
if not owner or not request.user.is_authenticated or owner != request.user:
raise owner_exception
return True
class PrivacyLevelPermission(BasePermission):
"""
Ensure the request user have acces to the object considering the privacylevel configuration
of the user. Could be expanded to other objects type.
"""
def has_object_permission(self, request, view, obj):
if not hasattr(obj, "user"):
# to do : it's a remote actor. We could trigger an update of the remote actor data
# to avoid leaking data
return True
if obj.user.privacy_level == "everyone":
return True
# user is anonymous
elif not hasattr(request.user, "actor"):
return False
elif obj.user.privacy_level == "instance":
# user is local
if hasattr(request.user, "actor"):
return True
elif request.actor.is_local():
return True
else:
return False
elif obj.user.privacy_level == "me" and obj.user.actor == request.user.actor:
return True
elif (
obj.user.privacy_level == "followers"
and request.user.actor in obj.user.actor.get_followers()
):
return True
else:
return False

Wyświetl plik

@ -29,7 +29,7 @@ def forward_to_scrobblers(listening, conf, **kwargs):
(username + " " + password).encode("utf-8")
).hexdigest()
cache_key = "lastfm:sessionkey:{}".format(
":".join([str(listening.user.pk), hashed_auth])
":".join([str(listening.actor.pk), hashed_auth])
)
PLUGIN["logger"].info("Forwarding scrobble to %s", LASTFM_SCROBBLER_URL)
session_key = PLUGIN["cache"].get(cache_key)

Wyświetl plik

@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer)
@record.registry.register_consumer("favorites.TrackFavorite")
def broadcast_track_favorite_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(

Wyświetl plik

@ -5,5 +5,5 @@ from . import models
@admin.register(models.TrackFavorite)
class TrackFavoriteAdmin(admin.ModelAdmin):
list_display = ["user", "track", "creation_date"]
list_select_related = ["user", "track"]
list_display = ["actor", "track", "creation_date"]
list_select_related = ["actor", "track"]

Wyświetl plik

@ -3,12 +3,28 @@ import factory
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.music.factories import TrackFactory
from funkwhale_api.users.factories import UserFactory
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.federation import models
from django.conf import settings
@registry.register
class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory):
track = factory.SubFactory(TrackFactory)
user = factory.SubFactory(UserFactory)
actor = factory.SubFactory(ActorFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta:
model = "favorites.TrackFavorite"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["domain", "fid"])

Wyświetl plik

@ -9,7 +9,7 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"]
)
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta:
model = models.TrackFavorite

Wyświetl plik

@ -0,0 +1,75 @@
# Generated by Django 4.2.9 on 2024-03-28 23:32
import uuid
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
row.uuid = uuid.uuid4()
row.save(update_fields=["uuid"])
# to do : test_migration (also for listening)
class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
("favorites", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
),
),
migrations.AddField(
model_name="trackfavorite",
name="fid",
field=models.URLField(
db_index=True,
default="https://default.fid",
max_length=500,
unique=True,
),
preserve_default=False,
),
migrations.AddField(
model_name="trackfavorite",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AlterField(
model_name="trackfavorite",
name="user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
]

Wyświetl plik

@ -0,0 +1,31 @@
# Generated by Django 4.2.9 on 2024-04-04 15:56
from django.db import migrations
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
("music", "0057_auto_20221118_2108"),
("favorites", "0002_trackfavorite_actor_trackfavorite_fid_and_more"),
]
operations = [
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.AlterUniqueTogether(
name="trackfavorite",
unique_together={("track", "actor")},
),
migrations.RemoveField(
model_name="trackfavorite",
name="user",
),
]

Wyświetl plik

@ -1,27 +1,88 @@
import uuid
from django.db import models
from django.utils import timezone
from django.urls import reverse
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.music.models import Track
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
FAVORITE_PRIVACY_LEVEL_CHOICES = [
(k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
]
class TrackFavorite(models.Model):
class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def viewable_by(self, actor):
if actor is None:
return self.filter(actor__user__privacy_level="everyone")
if hasattr(actor, "user"):
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
instance_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
instance_actor_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
return self.filter(
me_query
| instance_query
| instance_actor_query
| models.Q(actor__user__privacy_level="everyone")
)
class TrackFavorite(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(
"users.User", related_name="track_favorites", on_delete=models.CASCADE
actor = models.ForeignKey(
"federation.Actor",
related_name="track_favorites",
on_delete=models.CASCADE,
null=True,
blank=True,
)
track = models.ForeignKey(
Track, related_name="track_favorites", on_delete=models.CASCADE
)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "likes"
objects = TrackFavoriteQuerySet.as_manager()
class Meta:
unique_together = ("track", "user")
unique_together = ("track", "actor")
ordering = ("-creation_date",)
@classmethod
def add(cls, track, user):
favorite, created = cls.objects.get_or_create(user=user, track=track)
def add(cls, track, actor):
favorite, created = cls.objects.get_or_create(actor=actor, track=track)
return favorite
def get_activity_url(self):
return f"{self.user.get_activity_url()}/favorites/tracks/{self.pk}"
return f"{self.actor.get_absolute_url()}/favorites/tracks/{self.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

Wyświetl plik

@ -9,38 +9,28 @@ from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSer
from . import models
# to do : to deprecate ? this is only a local activity, the federated activities serializers are in `/federation`
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user")
actor = federation_serializers.APIActorSerializer(read_only=True)
published = serializers.DateTimeField(source="creation_date")
class Meta:
model = models.TrackFavorite
fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return "Like"
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta:
model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date", "actor")
actor = serializers.SerializerMethodField()
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
fields = ("id", "actor", "track", "creation_date", "actor")
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):

Wyświetl plik

@ -7,6 +7,7 @@ from rest_framework.response import Response
from config import plugins
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.federation import routes
from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
@ -23,7 +24,7 @@ class TrackFavoriteViewSet(
filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related(
"user__actor__attachment_icon"
"actor__attachment_icon"
)
permission_classes = [
oauth_permissions.ScopePermission,
@ -32,6 +33,7 @@ class TrackFavoriteViewSet(
required_scope = "favorites"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]:
@ -51,6 +53,10 @@ class TrackFavoriteViewSet(
confs=plugins.get_confs(self.request.user),
)
record.send(instance)
routes.outbox.dispatch(
{"type": "Create", "object": {"type": "Favorite"}},
context={"favorite": instance},
)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
@ -58,7 +64,9 @@ class TrackFavoriteViewSet(
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
fields.privacy_level_query(
self.request.user, "actor__user__privacy_level", "actor__user"
)
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
@ -70,7 +78,7 @@ class TrackFavoriteViewSet(
def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data["track"])
favorite = models.TrackFavorite.add(track=track, user=self.request.user)
favorite = models.TrackFavorite.add(track=track, actor=self.request.user.actor)
return favorite
@extend_schema(operation_id="unfavorite_track")
@ -78,9 +86,13 @@ class TrackFavoriteViewSet(
def remove(self, request, *args, **kwargs):
try:
pk = int(request.data["track"])
favorite = request.user.track_favorites.get(track__pk=pk)
favorite = request.user.actor.track_favorites.get(track__pk=pk)
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400)
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": "Favorite"}},
context={"favorite": favorite},
)
favorite.delete()
plugins.trigger_hook(
plugins.FAVORITE_DELETED,
@ -103,7 +115,9 @@ class TrackFavoriteViewSet(
if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=401)
favorites = request.user.track_favorites.values("id", "track").order_by("id")
favorites = request.user.actor.track_favorites.values("id", "track").order_by(
"id"
)
payload = serializers.AllFavoriteSerializer(favorites).data
return Response(payload, status=200)

Wyświetl plik

@ -55,6 +55,7 @@ FUNKWHALE_OBJECT_TYPES = [
("Album", "Album"),
("Track", "Track"),
("Library", "Library"),
("Favorite", "Favorite"),
]
OBJECT_TYPES = (
[
@ -119,6 +120,9 @@ def should_reject(fid, actor_id=None, payload={}):
@transaction.atomic
def receive(activity, on_behalf_of, inbox_actor=None):
"""
Receive an activity, find his recipients and save it to the database before dispatching it
"""
from funkwhale_api.moderation import mrf
from . import models, serializers, tasks
@ -223,6 +227,9 @@ class InboxRouter(Router):
"""
from . import api_serializers, models
logger.debug(
f"[federation] Inbox dispatch payload : {payload} with context : {context}"
)
handlers = self.get_matching_handlers(payload)
for handler in handlers:
if call_handlers:
@ -305,6 +312,7 @@ class OutboxRouter(Router):
from . import models, tasks
logger.debug(f"[federation] Outbox dispatch context : {context}")
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allowed_domains = None
if allow_list_enabled:
@ -446,11 +454,18 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
elif r == PUBLIC_ADDRESS:
urls.append(r)
elif isinstance(r, dict) and r["type"] == "followers":
# to do : rename user_received_follows to received_follows ? Could clash with Follow model
received_follows = (
r["target"]
.received_follows.filter(approved=True)
.select_related("actor__user")
)
if not received_follows and hasattr(r["target"], "received_user_follows"):
received_follows = (
r["target"]
.received_user_follows.filter(approved=True)
.select_related("actor__user")
)
for follow in received_follows:
actor = follow.actor
if actor.is_local:

Wyświetl plik

@ -77,6 +77,14 @@ class LibraryFollowAdmin(admin.ModelAdmin):
list_select_related = True
@admin.register(models.UserFollow)
class UserFollowAdmin(admin.ModelAdmin):
list_display = ["actor", "target", "approved", "creation_date"]
list_filter = ["approved"]
search_fields = ["actor__fid", "target__fid"]
list_select_related = True
@admin.register(models.InboxItem)
class InboxItemAdmin(admin.ModelAdmin):
list_display = ["actor", "activity", "type", "is_read"]

Wyświetl plik

@ -97,6 +97,30 @@ class LibraryFollowSerializer(serializers.ModelSerializer):
return federation_serializers.APIActorSerializer(o.actor).data
class UserFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField(
"fid", federation_serializers.APIActorSerializer(), required=True
)
actor = serializers.SerializerMethodField()
class Meta:
model = models.UserFollow
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
request_actor = self.context["actor"]
if v == request_actor:
raise serializers.ValidationError("You cannot follow yourself")
if v.received_user_follows.filter(actor=request_actor).exists():
raise serializers.ValidationError("You are already following this user")
return v
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj):
data = {"type": obj._meta.label}
if data["type"] == "federation.Actor":
@ -106,9 +130,11 @@ def serialize_generic_relation(activity, obj):
if data["type"] == "music.Library":
data["name"] = obj.name
if data["type"] == "federation.LibraryFollow":
if (
data["type"] == "federation.LibraryFollow"
or data["type"] == "federation.UserFollow"
):
data["approved"] = obj.approved
return data

Wyświetl plik

@ -5,6 +5,7 @@ from . import api_views
router = routers.OptionalSlashRouter()
router.register(r"fetches", api_views.FetchViewSet, "fetches")
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
router.register(r"follows/user", api_views.UserFollowViewSet, "user-follows")
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains")

Wyświetl plik

@ -311,3 +311,107 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)
)
@extend_schema_view(
list=extend_schema(operation_id="get_federation_user_follows"),
create=extend_schema(operation_id="create_federation_user_follow"),
)
class UserFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.UserFollow.objects.all()
.order_by("-creation_date")
.select_related("actor", "target")
)
serializer_class = api_serializers.UserFollowSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
# to do :
# filterset_class = filters.UserFollowFilter
ordering_fields = ("creation_date",)
@extend_schema(operation_id="get_federation_user_follow")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(operation_id="delete_federation_user_follow")
def destroy(self, request, uuid=None):
return super().destroy(request, uuid)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(
Q(target=self.request.user.actor) | Q(actor=self.request.user.actor)
).exclude(approved=False)
def perform_create(self, serializer):
follow = serializer.save(actor=self.request.user.actor)
routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance}
)
instance.delete()
def get_serializer_context(self):
context = super().get_serializer_context()
context["actor"] = self.request.user.actor
return context
@extend_schema(
operation_id="accept_federation_user_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.UserFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@extend_schema(operation_id="reject_federation_user_follow")
@decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.UserFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
@extend_schema(operation_id="get_all_federation_library_follows")
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__fid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "actor": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)

Wyświetl plik

@ -294,6 +294,8 @@ CONTEXTS = [
"Track": "fw:Track",
"Artist": "fw:Artist",
"Library": "fw:Library",
# might be possible to do "Favorite": "as:Like" ?
"Favorite": "fw:Favorite",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},

Wyświetl plik

@ -245,6 +245,15 @@ class LibraryFollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
model = "federation.LibraryFollow"
@registry.register
class UserFollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
target = factory.SubFactory(ActorFactory)
actor = factory.SubFactory(ActorFactory)
class Meta:
model = "federation.UserFollow"
class ArtistMetadataFactory(factory.Factory):
name = factory.Faker("name")

Wyświetl plik

@ -0,0 +1,60 @@
# Generated by Django 4.2.9 on 2024-03-27 17:28
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
("federation", "0028_auto_20221027_1141"),
]
operations = [
migrations.CreateModel(
name="UserFollow",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"fid",
models.URLField(blank=True, max_length=500, null=True, unique=True),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
("approved", models.BooleanField(default=None, null=True)),
(
"actor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_follows",
to="federation.actor",
),
),
(
"target",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="received_user_follows",
to="federation.actor",
),
),
],
options={
"unique_together": {("actor", "target")},
},
),
]

Wyświetl plik

@ -254,6 +254,8 @@ class Actor(models.Model):
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.privacy_level == "public":
return True
return False
def get_user(self):
@ -400,6 +402,8 @@ class Fetch(models.Model):
serializers.ChannelUploadSerializer,
],
contexts.FW.Library: [serializers.LibrarySerializer],
# to do : don't need to fetch a favorite since we can fetch the track and actor already ?
# contexts.FW.Favorite: [serializers.TrackFavoriteSerializer],
contexts.AS.Group: [serializers.ActorSerializer],
contexts.AS.Person: [serializers.ActorSerializer],
contexts.AS.Organization: [serializers.ActorSerializer],
@ -638,3 +642,15 @@ def update_denormalization_follow_deleted(sender, instance, **kwargs):
music_models.TrackActor.objects.filter(
actor=instance.actor, upload__in=instance.target.uploads.all()
).delete()
class UserFollow(AbstractFollow):
actor = models.ForeignKey(
Actor, related_name="user_follows", on_delete=models.CASCADE
)
target = models.ForeignKey(
Actor, related_name="received_user_follows", on_delete=models.CASCADE
)
class Meta:
unique_together = ["actor", "target"]

Wyświetl plik

@ -5,6 +5,8 @@ from django.db.models import Q
from funkwhale_api.music import models as music_models
from funkwhale_api.favorites import models as favorites_models
from . import activity, actors, models, serializers
logger = logging.getLogger(__name__)
@ -608,3 +610,81 @@ def outbox_delete_album(context):
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Create", "object.type": "Favorite"})
def outbox_create_favorite(context):
from funkwhale_api.favorites import serializers as favorites_serializers
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": serializers.TrackFavoriteSerializer(favorite).data,
"actor": actor.fid,
}
)
yield {
"type": "Create",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
"object": favorite,
"target": actor,
}
@outbox.register({"type": "Delete", "object.type": "Favorite"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
"object": serializers.TrackFavoriteSerializer(favorite).data,
"actor": actor.fid,
}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
"object": favorite,
"target": actor,
}
@inbox.register({"type": "Create", "object.type": "Favorite"})
def inbox_create_favorite(payload, context):
from funkwhale_api.favorites import serializers as favorites_serializers
actor = context["actor"]
favorite = payload["object"]
serializer = serializers.TrackFavoriteSerializer(data=favorite)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Favorite"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
favorite_id = payload["object"].get("id")
query = Q(fid=favorite_id) & Q(actor=actor)
try:
favorite = favorites_models.TrackFavorite.objects.get(query)
except favorites_models.TrackFavorite.DoesNotExist:
logger.debug("Discarding deletion of unkwnown favorite %s", favorite_id)
return
favorite.delete()

Wyświetl plik

@ -12,6 +12,7 @@ from rest_framework import serializers
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
@ -20,7 +21,7 @@ from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__)
@ -644,9 +645,14 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs):
target = self.validated_data["object"]
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
elif (
target._meta.label == "federation.Actor"
and target.type == "Person"
and not target.get_channel()
):
follow_class = models.UserFollow
else:
follow_class = models.Follow
defaults = kwargs
@ -723,6 +729,10 @@ class FollowActionSerializer(serializers.Serializer):
if target._meta.label == "music.Library":
expected = target.actor
follow_class = models.LibraryFollow
# to do : what if the follow is an AP follow of an non fw object ?
elif target._meta.label == "federation.Actor" and not target.get_channel():
expected = target
follow_class = models.UserFollow
else:
expected = target
follow_class = models.Follow
@ -804,6 +814,8 @@ class UndoFollowSerializer(serializers.Serializer):
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
elif target._meta.label == "federation.Actor" and not target.get_channel():
follow_class = models.UserFollow
else:
follow_class = models.Follow
@ -812,7 +824,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target
).get()
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove")
raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data
def to_representation(self, instance):
@ -879,7 +893,6 @@ class ActivitySerializer(serializers.Serializer):
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
@ -2076,3 +2089,47 @@ class IndexSerializer(jsonld.JsonLdSerializer):
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
# to do : should thi be target like followserializer ?
track = TrackSerializer(required=True)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"track": jsonld.first_obj(contexts.FW.track),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, favorite):
payload = {
"type": "Favorite",
"id": favorite.fid,
"actor": favorite.actor.fid,
"track": TrackSerializer(
favorite.track, context={"include_ap_context": False}
).data,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["track"]["id"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
user=None,
)

Wyświetl plik

@ -19,6 +19,8 @@ music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
index_router.register(r"index", views.IndexViewSet, "index")

Wyświetl plik

@ -7,8 +7,11 @@ from django.urls import reverse
from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
@ -170,17 +173,83 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
collection_serializer=serializers.ChannelOutboxSerializer(channel),
)
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def followers(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followers = list(actor.get_approved_followers())
followers.extend(
actor.received_user_follows.filter(approved=True).values_list(
"actor", flat=True
)
)
actors_followers = models.Actor.objects.filter(pk__in=followers)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": actors_followers,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def following(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followings = list(
actor.emitted_follows.filter(approved=True).values_list("target", flat=True)
)
followings.extend(
actor.user_follows.filter(approved=True).values_list("target", flat=True)
)
actors_followings = models.Actor.objects.filter(pk__in=followings).order_by(
"preferred_username"
)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": actors_followings,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def listens(self, request, *args, **kwargs):
actor = self.get_object()
# to do : listens endpoint :
history_models.Listening.objects.filter(actor=actor)
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
@ -527,3 +596,43 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
)
return response.Response({}, status=200)
# to do : this should follow privacy_level setting
class TrackFavoriteViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = favorites_models.TrackFavorite.objects.local().select_related(
"track", "actor"
)
serializer_class = serializers.TrackFavoriteSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ListeningsViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = history_models.Listening.objects.local().select_related("track", "actor")
# to do :
# serializer_class = serializers.ListeningSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)

Wyświetl plik

@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(

Wyświetl plik

@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"]
search_fields = ["track__name", "user__username"]
list_select_related = ["user", "track"]
list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "actor__user__username"]
list_select_related = ["actor", "track"]

Wyświetl plik

@ -1,14 +1,15 @@
import factory
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.music import factories
from funkwhale_api.users.factories import UserFactory
from funkwhale_api.federation.factories import ActorFactory
@registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta:
model = "history.Listening"

Wyświetl plik

@ -0,0 +1,60 @@
# Generated by Django 4.2.9 on 2024-03-28 23:32
import uuid
from django.db import migrations, models
import django.db.models.deletion
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
row.uuid = uuid.uuid4()
row.save(update_fields=["uuid"])
class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
("history", "0002_auto_20180325_1433"),
]
operations = [
migrations.AddField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
migrations.AddField(
model_name="listening",
name="fid",
field=models.URLField(
db_index=True,
default="https://default.fid",
max_length=500,
unique=True,
),
preserve_default=False,
),
migrations.AddField(
model_name="listening",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
]

Wyświetl plik

@ -0,0 +1,25 @@
# Generated by Django 4.2.9 on 2024-04-04 15:10
from django.db import migrations
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_actor_listening_fid_listening_url"),
]
operations = [
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="listening",
name="user",
),
]

Wyświetl plik

@ -1,26 +1,59 @@
import uuid
from django.db import models
from django.utils import timezone
from django.urls import reverse
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track
class Listening(models.Model):
class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
class Listening(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE
)
user = models.ForeignKey(
"users.User",
# if actor is null it's a local TrackFavorite, maybe we should use `attributed_to` ?
# Maybe we should use user instead : if user is null it's a remote object :
# and delete the user attribute, but might be more work
actor = models.ForeignKey(
"federation.Actor",
related_name="listenings",
on_delete=models.CASCADE,
null=True,
blank=True,
on_delete=models.CASCADE,
)
session_key = models.CharField(max_length=100, null=True, blank=True)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "listenings"
objects = TrackFavoriteQuerySet.as_manager()
class Meta:
ordering = ("-creation_date",)
def get_activity_url(self):
return f"{self.user.get_activity_url()}/listenings/tracks/{self.pk}"
return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

Wyświetl plik

@ -12,47 +12,37 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user")
actor = federation_serializers.APIActorSerializer()
published = serializers.DateTimeField(source="creation_date")
class Meta:
model = models.Listening
fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return "Listen"
class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date", "actor")
fields = ("id", "actor", "track", "creation_date", "actor")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class ListeningWriteSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
fields = ("id", "actor", "track", "creation_date")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)

Wyświetl plik

@ -18,9 +18,7 @@ class ListeningViewSet(
viewsets.GenericViewSet,
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related(
"user__actor__attachment_icon"
)
queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
permission_classes = [
oauth_permissions.ScopePermission,
@ -29,6 +27,7 @@ class ListeningViewSet(
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter
def get_serializer_class(self):
@ -49,7 +48,7 @@ class ListeningViewSet(
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
fields.privacy_level_query(self.request.user, "actor__user__privacy_level")
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)

Wyświetl plik

@ -148,7 +148,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
track_ids = (
kwargs["user"].actor.track_favorites.all().values_list("track", flat=True)
)
return qs.filter(pk__in=track_ids, artist__content_category="music")
@ -334,7 +336,9 @@ class LessListenedRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
return (
qs.filter(artist__content_category="music")
.exclude(pk__in=listened)
@ -350,7 +354,9 @@ class LessListenedLibraryRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True
)

Wyświetl plik

@ -188,7 +188,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
track_ids = (
kwargs["user"].actor.track_favorites.all().values_list("track", flat=True)
)
return qs.filter(pk__in=track_ids, artist__content_category="music")
@ -374,7 +376,9 @@ class LessListenedRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
return (
qs.filter(artist__content_category="music")
.exclude(pk__in=listened)
@ -390,7 +394,9 @@ class LessListenedLibraryRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True
)

Wyświetl plik

@ -314,7 +314,7 @@ class ScrobbleSerializer(serializers.Serializer):
def create(self, data):
return history_models.Listening.objects.create(
user=self.context["user"], track=data["id"]
actor=self.context["user"].actor, track=data["id"]
)

Wyświetl plik

@ -333,14 +333,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
@find_object(music_models.Track.objects.all())
def star(self, request, *args, **kwargs):
track = kwargs.pop("obj")
TrackFavorite.add(user=request.user, track=track)
TrackFavorite.add(actor=request.user.actor, track=track)
return response.Response({"status": "ok"})
@action(detail=False, methods=["get", "post"], url_name="unstar", url_path="unstar")
@find_object(music_models.Track.objects.all())
def unstar(self, request, *args, **kwargs):
track = kwargs.pop("obj")
request.user.track_favorites.filter(track=track).delete()
request.user.actor.track_favorites.filter(track=track).delete()
return response.Response({"status": "ok"})
@action(
@ -350,7 +350,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getStarred2",
)
def get_starred2(self, request, *args, **kwargs):
favorites = request.user.track_favorites.all()
favorites = request.user.actor.track_favorites.all()
data = {"starred2": {"song": serializers.get_starred_tracks_data(favorites)}}
return response.Response(data)
@ -438,7 +438,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getStarred",
)
def get_starred(self, request, *args, **kwargs):
favorites = request.user.track_favorites.all()
favorites = request.user.actor.track_favorites.all()
data = {"starred": {"song": serializers.get_starred_tracks_data(favorites)}}
return response.Response(data)

Wyświetl plik

@ -0,0 +1,22 @@
# to do : to delete
# from django.urls import re_path, include
# from funkwhale_api.common import routers
# from funkwhale_api.federation import api_views as federation_views
# from . import views_v2
# router = routers.OptionalSlashRouter()
# router.register(r"", views_v2.UserViewSet, "users")
# urlpatterns = [
# re_path(r"^login/?$", views_v2.login, name="login"),
# re_path(r"^logout/?$", views_v2.logout, name="logout"),
# re_path(
# r"^(?P<user_pk>[0-9]+)/follow_requests/(?P<follow_pk>[0-9]+)/?$",
# views_v2.follow_request_patch,
# name="follow_request_patch",
# ),
# ] + router.urls

Wyświetl plik

@ -13,7 +13,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from funkwhale_api.common import preferences, throttling
from funkwhale_api.federation import routes
from . import models, serializers, tasks
@ -94,6 +94,9 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
"""Return information about the current user or delete it"""
new_settings = request.data
request.user.set_settings(**new_settings)
# to do : privacy downgrade
if "privacy_level" in new_settings:
dispatch_privacy_downgrade(new_settings["privacy_level"], request.user)
return Response(request.user.settings)
@action(
@ -179,3 +182,12 @@ def logout(request):
response = http.HttpResponse(status=200)
response.set_cookie("csrftoken", token, max_age=None)
return response
# to do : privacy downgrade
def dispatch_privacy_downgrade(privacy_level, user):
if privacy_level == "me" or privacy_level == "instance":
routes.outbox.dispatch({"type": "Delete"}, context={"actor": user.actor})
if privacy_level == "followers":
routes.outbox.dispatch({"type": "Update"}, context={"actor": user.actor})

Wyświetl plik

@ -0,0 +1,270 @@
# to do : to delete import json
# from allauth.account.adapter import get_adapter
# from allauth.account.utils import send_email_confirmation
# from dj_rest_auth import views as rest_auth_views
# from dj_rest_auth.registration import views as registration_views
# from django import http
# from django.contrib import auth
# from django.middleware import csrf
# from drf_spectacular.utils import extend_schema, extend_schema_view
# from rest_framework import mixins, viewsets, exceptions
# from rest_framework.decorators import action, api_view
# from rest_framework.response import Response
# from funkwhale_api.common import preferences, throttling
# from . import models, serializers, tasks
# from funkwhale_api.federation import models as federation_models
# from funkwhale_api.federation import api_serializers as api_federation_serializers
# from funkwhale_api.federation import serializers as federation_serializers
# from funkwhale_api.federation import routes
# from . import models, serializers, tasks
# from django.shortcuts import get_object_or_404
# class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
# queryset = models.User.objects.all().select_related("actor__attachment_icon")
# serializer_class = serializers.UserWriteSerializer
# lookup_field = "pk"
# lookup_value_regex = r"[a-zA-Z0-9-_.]+"
# required_scope = "profile"
# @extend_schema(operation_id="follow_requests")
# @action(methods=["post"], detail=True)
# def follow_requests(self, *args, **kwargs):
# user_id = kwargs["pk"]
# actor = self.request.user.actor
# serializer = api_federation_serializers.UserFollowSerializer(
# data={"actor": actor, "target": user_id},
# context={"actor": self.request.user.actor},
# )
# serializer.is_valid(raise_exception=True)
# follow = serializer.save(actor=self.request.user.actor)
# routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
# return Response(status=204)
# @extend_schema(operation_id="unfollow")
# @action(methods=["post"], detail=True)
# def unfollow(self, *args, **kwargs):
# follow = get_object_or_404(
# federation_models.UserFollow,
# actor=self.request.user.actor,
# target=self.get_object(),
# )
# follow.delete()
# routes.outbox.dispatch({"type": "Delete"}, context={"follow": follow})
# return Response(status=200)
# @extend_schema(operation_id="followings")
# @action(
# methods=["get"],
# detail=True,
# )
# def followings(self, *args, **kwargs):
# user = self.get_object()
# if (
# self.request.user != self.get_object()
# and self.get_object().privacy_level == "private"
# ):
# raise exceptions.PermissionDenied
# if (
# self.request.user != self.get_object()
# and self.request.user.actor.is_local is False
# and self.get_object().privacy_level == "pod"
# ):
# raise exceptions.PermissionDenied
# followings = federation_models.UserFollow.objects.filter(
# actor=user.actor
# ).order_by("creation_date")
# serializer = api_federation_serializers.UserFollowSerializer(
# followings, many=True
# )
# page = self.paginate_queryset(followings)
# if page is not None:
# serializer = api_federation_serializers.UserFollowSerializer(
# page, many=True, required=False
# )
# return self.get_paginated_response(serializer.data)
# serializer = api_federation_serializers.UserFollowSerializer(
# followings, many=True, required=False
# )
# return Response(serializer.data)
# @extend_schema(operation_id="followers")
# @action(
# methods=["get"],
# detail=True,
# )
# def followers(self, *args, **kwargs):
# user = self.get_object()
# if (
# self.request.user != self.get_object()
# and self.get_object().privacy_level == "private"
# ):
# raise exceptions.PermissionDenied
# if (
# self.request.user != self.get_object()
# and self.request.actor.is_local is False
# and self.get_object().privacy_level == "pod"
# ):
# raise exceptions.PermissionDenied
# followers = federation_models.UserFollow.objects.filter(target=user).order_by(
# "creation_date"
# )
# serializer = api_federation_serializers.UserFollowSerializer(
# followers, many=True
# )
# page = self.paginate_queryset(followers)
# if page is not None:
# serializer = api_federation_serializers.UserFollowSerializer(
# page, many=True, required=False
# )
# return self.get_paginated_response(serializer.data)
# serializer = api_federation_serializers.UserFollowSerializer(
# followers, many=True, required=False
# )
# return Response(serializer.data)
# @extend_schema(operation_id="get_authenticated_user", methods=["get"])
# @extend_schema(operation_id="delete_authenticated_user", methods=["delete"])
# @action(methods=["get", "delete"], detail=False)
# def me(self, request, *args, **kwargs):
# """Return information about the current user or delete it"""
# if request.method.lower() == "delete":
# serializer = serializers.UserDeleteSerializer(
# request.user, data=request.data
# )
# serializer.is_valid(raise_exception=True)
# tasks.delete_account.delay(user_id=request.user.pk)
# # at this point, password is valid, we launch deletion
# return Response(status=204)
# serializer = serializers.MeSerializer(request.user)
# return Response(serializer.data)
# @extend_schema(operation_id="update_settings")
# @action(methods=["post"], detail=False, url_name="settings", url_path="settings")
# def set_settings(self, request, *args, **kwargs):
# """Return information about the current user or delete it"""
# new_settings = request.data
# request.user.set_settings(**new_settings)
# return Response(request.user.settings)
# @action(
# methods=["get", "post", "delete"],
# required_scope="security",
# url_path="subsonic-token",
# detail=True,
# )
# def subsonic_token(self, request, *args, **kwargs):
# if not self.request.user.username == kwargs.get("username"):
# return Response(status=403)
# if not preferences.get("subsonic__enabled"):
# return Response(status=405)
# if request.method.lower() == "get":
# return Response(
# {"subsonic_api_token": self.request.user.subsonic_api_token}
# )
# if request.method.lower() == "delete":
# self.request.user.subsonic_api_token = None
# self.request.user.save(update_fields=["subsonic_api_token"])
# return Response(status=204)
# self.request.user.update_subsonic_api_token()
# self.request.user.save(update_fields=["subsonic_api_token"])
# data = {"subsonic_api_token": self.request.user.subsonic_api_token}
# return Response(data)
# @extend_schema(operation_id="change_email", responses={200: None, 403: None})
# @action(
# methods=["post"],
# required_scope="security",
# url_path="change-email",
# detail=False,
# )
# def change_email(self, request, *args, **kwargs):
# if not self.request.user.is_authenticated:
# return Response(status=403)
# serializer = serializers.UserChangeEmailSerializer(
# request.user, data=request.data, context={"user": request.user}
# )
# serializer.is_valid(raise_exception=True)
# serializer.save(request)
# return Response(status=204)
# def update(self, request, *args, **kwargs):
# if not self.request.user.username == kwargs.get("username"):
# return Response(status=403)
# return super().update(request, *args, **kwargs)
# def partial_update(self, request, *args, **kwargs):
# if not self.request.user.username == kwargs.get("username"):
# return Response(status=403)
# return super().partial_update(request, *args, **kwargs)
# @extend_schema(operation_id="login")
# @action(methods=["post"], detail=False)
# def login(request):
# throttling.check_request(request, "login")
# if request.method != "POST":
# return http.HttpResponse(status=405)
# serializer = serializers.LoginSerializer(
# data=request.POST, context={"request": request}
# )
# if not serializer.is_valid():
# return http.HttpResponse(
# json.dumps(serializer.errors), status=400, content_type="application/json"
# )
# serializer.save(request)
# csrf.rotate_token(request)
# token = csrf.get_token(request)
# response = http.HttpResponse(status=200)
# response.set_cookie("csrftoken", token, max_age=None)
# return response
# @extend_schema(operation_id="logout")
# @action(methods=["post"], detail=False)
# def logout(request):
# if request.method != "POST":
# return http.HttpResponse(status=405)
# auth.logout(request)
# token = csrf.get_token(request)
# response = http.HttpResponse(status=200)
# response.set_cookie("csrftoken", token, max_age=None)
# return response
# @action(methods=["patch"], detail=False)
# @extend_schema(operation_id="follow_request_patch")
# def follow_request_patch(request, user_pk, follow_pk):
# try:
# user = models.User.objects.get(pk=user_pk)
# follow = federation_models.UserFollow.objects.get(pk=follow_pk)
# except (models.User.DoesNotExist, federation_models.UserFollow.DoesNotExist):
# raise http.Http404
# if request.user != user:
# raise exceptions.PermissionDenied
# request_body = request.body.decode("utf-8")
# data = json.loads(request_body)
# if not isinstance(data["approved"], bool):
# raise BaseException("Approved typemust be boolean")
# follow.approved = data["approved"]
# follow.save()
# routes.outbox.dispatch({"type": "Update"}, context={"follow": follow})
# return http.HttpResponse(status=204)

Wyświetl plik

@ -2,18 +2,22 @@ from funkwhale_api.activity import utils
def test_get_activity(factories):
user = factories["users.User"]()
listening = factories["history.Listening"]()
favorite = factories["favorites.TrackFavorite"]()
user = factories["users.User"](with_actor=True)
# to do : only support local activities update to suport federated activities
activity_user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=activity_user.actor)
favorite = factories["favorites.TrackFavorite"](actor=activity_user.actor)
objects = list(utils.get_activity(user))
assert objects == [favorite, listening]
def test_get_activity_honors_privacy_level(factories, anonymous_user):
factories["history.Listening"](user__privacy_level="me")
favorite1 = factories["favorites.TrackFavorite"](user__privacy_level="everyone")
factories["favorites.TrackFavorite"](user__privacy_level="instance")
user = factories["users.User"](privacy_level="me")
user2 = factories["users.User"](privacy_level="instance")
factories["history.Listening"](actor=user.actor)
favorite1 = factories["favorites.TrackFavorite"](actor=user.actor)
factories["favorites.TrackFavorite"](actor=user2.actor)
objects = list(utils.get_activity(anonymous_user))
assert objects == [favorite1]

Wyświetl plik

@ -5,7 +5,8 @@ from funkwhale_api.activity import serializers, utils
def test_activity_view(factories, api_client, preferences, anonymous_user):
preferences["common__api_authentication_required"] = False
factories["favorites.TrackFavorite"](user__privacy_level="everyone")
user = factories["users.User"](privacy_level="everyone")
factories["favorites.TrackFavorite"](actor=user.actor)
factories["history.Listening"]()
url = reverse("api:v1:activity-list")
objects = utils.get_activity(anonymous_user)

Wyświetl plik

@ -39,3 +39,55 @@ def test_owner_permission_read_only(anonymous_user, nodb_factories, api_request)
check = permission.has_object_permission(request, view, playlist)
assert check is True
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("instance", False), ("everyone", True)],
)
def test_privacylevel_permission_anonymous(
factories, api_request, anonymous_user, privacy_level, expected
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", anonymous_user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("instance", True), ("everyone", True)],
)
def test_privacylevel_permission_instance(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
request_user = factories["users.User"](with_actor=True)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", request_user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", True), ("instance", True), ("everyone", True)],
)
def test_privacylevel_permission_me(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected

Wyświetl plik

@ -1,19 +1,20 @@
from funkwhale_api.favorites import activities, serializers
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.federation.serializers import APIActorSerializer
def test_get_favorite_activity_url(settings, factories):
favorite = factories["favorites.TrackFavorite"]()
user_url = favorite.user.get_activity_url()
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
user_url = favorite.actor.user.get_activity_url()
expected = f"{user_url}/favorites/tracks/{favorite.pk}"
assert favorite.get_activity_url() == expected
def test_activity_favorite_serializer(factories):
favorite = factories["favorites.TrackFavorite"]()
actor = UserActivitySerializer(favorite.user).data
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
actor = APIActorSerializer(favorite.actor).data
field = serializers.serializers.DateTimeField()
expected = {
"type": "Like",
@ -42,7 +43,8 @@ def test_track_favorite_serializer_instance_activity_consumer(activity_registry)
def test_broadcast_track_favorite_to_instance_activity(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
favorite = factories["favorites.TrackFavorite"]()
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
data = serializers.TrackFavoriteActivitySerializer(favorite).data
consumer = activities.broadcast_track_favorite_to_instance_activity
message = {"type": "event.send", "text": "", "data": data}
@ -52,7 +54,8 @@ def test_broadcast_track_favorite_to_instance_activity(factories, mocker):
def test_broadcast_track_favorite_to_instance_activity_private(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
favorite = factories["favorites.TrackFavorite"](user__privacy_level="me")
user = factories["users.User"](privacy_level="me", with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
data = serializers.TrackFavoriteActivitySerializer(favorite).data
consumer = activities.broadcast_track_favorite_to_instance_activity
consumer(data=data, obj=favorite)

Wyświetl plik

@ -9,11 +9,11 @@ from funkwhale_api.favorites.models import TrackFavorite
def test_user_can_add_favorite(factories):
track = factories["music.Track"]()
user = factories["users.User"]()
f = TrackFavorite.add(track, user)
user = factories["users.User"](with_actor=True)
f = TrackFavorite.add(track, user.actor)
assert f.track == track
assert f.user == user
assert f.actor.user == user
def test_user_can_get_his_favorites(
@ -21,7 +21,9 @@ def test_user_can_get_his_favorites(
):
request = api_request.get("/")
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
factories["favorites.TrackFavorite"]()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.get(url, {"scope": "me"})
@ -38,7 +40,10 @@ def test_user_can_get_his_favorites(
def test_user_can_retrieve_all_favorites_at_once(
api_request, factories, logged_in_api_client, client
):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
factories["favorites.TrackFavorite"]()
url = reverse("api:v1:favorites:tracks-all")
response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk})
@ -49,6 +54,8 @@ def test_user_can_retrieve_all_favorites_at_once(
def test_user_can_add_favorite_via_api(factories, logged_in_api_client, activity_muted):
track = factories["music.Track"]()
logged_in_api_client.user.create_actor()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.post(url, {"track": track.pk})
@ -62,12 +69,13 @@ def test_user_can_add_favorite_via_api(factories, logged_in_api_client, activity
assert expected == parsed_json
assert favorite.track == track
assert favorite.user == logged_in_api_client.user
assert favorite.actor.user == logged_in_api_client.user
def test_adding_favorites_calls_activity_record(
factories, logged_in_api_client, activity_muted
):
logged_in_api_client.user.create_actor()
track = factories["music.Track"]()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.post(url, {"track": track.pk})
@ -82,13 +90,16 @@ def test_adding_favorites_calls_activity_record(
assert expected == parsed_json
assert favorite.track == track
assert favorite.user == logged_in_api_client.user
assert favorite.actor.user == logged_in_api_client.user
activity_muted.assert_called_once_with(favorite)
def test_user_can_remove_favorite_via_api(logged_in_api_client, factories):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
url = reverse("api:v1:favorites:tracks-detail", kwargs={"pk": favorite.pk})
response = logged_in_api_client.delete(url, {"track": favorite.track.pk})
assert response.status_code == 204
@ -99,7 +110,10 @@ def test_user_can_remove_favorite_via_api(logged_in_api_client, factories):
def test_user_can_remove_favorite_via_api_using_track_id(
method, factories, logged_in_api_client
):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
url = reverse("api:v1:favorites:tracks-remove")
response = getattr(logged_in_api_client, method)(
@ -119,7 +133,9 @@ def test_url_require_auth(url, method, db, preferences, client):
def test_can_filter_tracks_by_favorites(factories, logged_in_api_client):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
url = reverse("api:v1:tracks-list")
response = logged_in_api_client.get(url, data={"favorites": True})

Wyświetl plik

@ -0,0 +1,33 @@
import pytest
from funkwhale_api.favorites import models
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("instance", True), ("everyone", True)],
)
def test_playable_by_local_actor(privacy_level, expected, factories):
actor = factories["federation.Actor"](local=True)
# default user actor is local
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
queryset = models.TrackFavorite.objects.all().viewable_by(actor)
match = favorite in list(queryset)
assert match is expected
@pytest.mark.parametrize(
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
)
def test_not_playable_by_remote_actor(privacy_level, expected, factories):
actor = factories["federation.Actor"]()
# default user actor is local
user = factories["users.User"](
with_actor=True,
privacy_level=privacy_level,
)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
queryset = models.TrackFavorite.objects.all().viewable_by(actor)
match = favorite in list(queryset)
assert match is expected

Wyświetl plik

@ -1,19 +1,16 @@
from funkwhale_api.favorites import serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users import serializers as users_serializers
def test_track_favorite_serializer(factories, to_api_date):
favorite = factories["favorites.TrackFavorite"]()
actor = favorite.user.create_actor()
expected = {
"id": favorite.pk,
"creation_date": to_api_date(favorite.creation_date),
"track": music_serializers.TrackSerializer(favorite.track).data,
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(favorite.user).data,
"actor": federation_serializers.APIActorSerializer(favorite.actor).data,
}
serializer = serializers.UserTrackFavoriteSerializer(favorite)

Wyświetl plik

@ -5,7 +5,8 @@ from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
factories["favorites.TrackFavorite"](user__privacy_level=level)
user = factories["users.User"](with_actor=True, privacy_level=level)
factories["favorites.TrackFavorite"](actor=user.actor)
url = reverse("api:v1:favorites:tracks-list")
response = api_client.get(url)
assert response.status_code == 200

Wyświetl plik

@ -180,3 +180,17 @@ def test_fetch_serializer_unhandled_obj(factories, to_api_date):
}
assert api_serializers.FetchSerializer(fetch).data == expected
def test_user_follow_serializer_do_not_allow_already_followed(factories):
actor = factories["federation.Actor"]()
follow = factories["federation.UserFollow"](actor=actor)
serializer = api_serializers.UserFollowSerializer(context={"actor": actor})
with pytest.raises(
api_serializers.serializers.ValidationError, match=r"You cannot follow yourself"
):
serializer.validate_target(actor)
with pytest.raises(api_serializers.serializers.ValidationError, match=r"already"):
serializer.validate_target(follow.target)

Wyświetl plik

@ -316,3 +316,120 @@ def test_library_follow_get_all(factories, logged_in_api_client):
],
"count": 1,
}
def test_user_follow_get_all(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
target_actor = factories["federation.Actor"]()
follow = factories["federation.UserFollow"](target=target_actor, actor=actor)
factories["federation.UserFollow"]()
url = reverse("api:v1:federation:user-follows-all")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
"results": [
{
"uuid": str(follow.uuid),
"actor": str(target_actor.fid),
"approved": follow.approved,
}
],
"count": 1,
}
def test_user_follow_retrieve(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
target_actor = factories["federation.Actor"]()
follow = factories["federation.UserFollow"](target=target_actor, actor=actor)
factories["federation.UserFollow"]()
url = reverse("api:v1:federation:user-follows-detail", kwargs={"uuid": follow.uuid})
response = logged_in_api_client.get(url)
assert response.status_code == 200
def test_user_can_list_their_user_follows(factories, logged_in_api_client):
# followed by someont else
factories["federation.UserFollow"]()
follow = factories["federation.UserFollow"](actor__user=logged_in_api_client.user)
url = reverse("api:v1:federation:user-follows-list")
response = logged_in_api_client.get(url)
assert response.data["count"] == 1
assert response.data["results"][0]["uuid"] == str(follow.uuid)
def test_can_follow_user_actor(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
target_actor = factories["federation.Actor"]()
url = reverse("api:v1:federation:user-follows-list")
response = logged_in_api_client.post(url, {"target": target_actor.fid})
assert response.status_code == 201
follow = target_actor.received_user_follows.latest("id")
assert follow.approved is None
assert follow.actor == actor
dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow})
def test_can_undo_user_follow(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
follow = factories["federation.UserFollow"](actor=actor)
delete = mocker.patch.object(follow.__class__, "delete")
url = reverse("api:v1:federation:user-follows-detail", kwargs={"uuid": follow.uuid})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
delete.assert_called_once_with()
dispatch.assert_called_once_with(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": follow}
)
@pytest.mark.parametrize("action", ["accept", "reject"])
def test_user_cannot_edit_someone_else_user_follow(
factories, logged_in_api_client, action
):
logged_in_api_client.user.create_actor()
follow = factories["federation.UserFollow"]()
url = reverse(
f"api:v1:federation:user-follows-{action}",
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize("action,expected", [("accept", True), ("reject", False)])
def test_user_can_accept_or_reject_own_user_follows(
factories, logged_in_api_client, action, expected, mocker
):
mocked_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
actor = logged_in_api_client.user.create_actor()
follow = factories["federation.UserFollow"](target=actor)
url = reverse(
f"api:v1:federation:user-follows-{action}",
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 204
follow.refresh_from_db()
assert follow.approved is expected
mocked_dispatch.assert_called_once_with(
{"type": action.title()}, context={"follow": follow}
)

Wyświetl plik

@ -7,8 +7,11 @@ from funkwhale_api.federation import (
jsonld,
routes,
serializers,
utils,
)
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.favorites import serializers as favorites_serializers
@pytest.mark.parametrize(
@ -36,6 +39,10 @@ from funkwhale_api.moderation import serializers as moderation_serializers
({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor),
({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete),
({"type": "Flag"}, routes.inbox_flag),
(
{"type": "Create", "object": {"type": "Favorite"}},
routes.inbox_create_favorite,
),
],
)
def test_inbox_routes(route, handler):
@ -82,6 +89,10 @@ def test_inbox_routes(route, handler):
{"type": "Delete", "object": {"type": "Organization"}},
routes.outbox_delete_actor,
),
(
{"type": "Create", "object": {"type": "Favorite"}},
routes.outbox_create_favorite,
),
],
)
def test_outbox_routes(route, handler):
@ -127,6 +138,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
)
# to do : autoapprove
def test_inbox_follow_user_autoapprove(factories, mocker):
mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
local_actor = factories["users.User"](privacy_level="public").create_actor()
remote_actor = factories["federation.Actor"]()
ii = factories["federation.InboxItem"](actor=local_actor)
payload = {
"type": "Follow",
"id": "https://test.follow",
"actor": remote_actor.fid,
"object": local_actor.fid,
}
result = routes.inbox_follow(
payload,
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
)
follow = local_actor.received_user_follows.latest("id")
assert result["object"] == local_actor
assert result["related_object"] == follow
assert follow.fid == payload["id"]
assert follow.actor == remote_actor
assert follow.approved is True
mocked_outbox_dispatch.assert_called_once_with(
{"type": "Accept"}, context={"follow": follow}
)
def test_inbox_follow_channel_autoapprove(factories, mocker):
mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
@ -988,3 +1034,84 @@ def test_outbox_flag(factory_name, factory_kwargs, factories, mocker):
expected["to"] = [{"type": "actor_inbox", "actor": report.target_owner}]
assert activity["payload"] == expected
assert activity["actor"] == actors.get_service_actor()
def test_outbox_create_favorite(factories, mocker):
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
activity = list(routes.outbox_create_favorite({"favorite": favorite}))[0]
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": serializers.TrackFavoriteSerializer(favorite).data,
"actor": favorite.actor.fid,
}
)
expected = serializer.data
expected["to"] = [{"type": "followers", "target": favorite.actor}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == favorite.actor
assert activity["target"] == favorite.actor.received_user_follows.all()
assert activity["object"] == favorite
def test_inbox_create_favorite(factories, mocker):
actor = factories["federation.Actor"]()
favorite = factories["favorites.TrackFavorite"](actor=actor)
follow = factories["federation.UserFollow"](target=actor)
data = serializers.TrackFavoriteSerializer(favorite).data
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": data,
"actor": actor.fid,
}
)
init = mocker.spy(serializers.TrackFavoriteSerializer, "__init__")
save = mocker.spy(serializers.TrackFavoriteSerializer, "save")
track_data = serializers.TrackSerializer(favorite.track).data
mocker.patch.object(utils, "retrieve_ap_object", return_value=favorite.track)
favorite.delete()
result = routes.inbox_create_favorite(
serializer.data,
context={
"actor": favorite.actor,
"raise_exception": True,
},
)
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == serializers.TrackFavoriteSerializer(result["object"]).data
# assert args[1]["context"] == {"activity": activity, "actor": favorite.actor}
assert save.call_count == 1
assert favorites_models.TrackFavorite.objects.filter(
track=favorite.track, actor=favorite.actor
).exists()
def test_routes_user(factories):
favorite = factories["favorites.TrackFavorite"]()
follow = factories["federation.UserFollow"](target=favorite.actor, approved=True)
data = serializers.TrackFavoriteSerializer(favorite).data
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": data,
"actor": favorite.actor.fid,
}
)
activities = routes.inbox.dispatch(
serializer.data,
context={
"activity": serializer.data,
"actor": favorite.actor.fid,
# "inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"),
},
)
assert len(activities) == 1

Wyświetl plik

@ -282,6 +282,7 @@ def test_accept_follow_serializer_representation(factories):
def test_accept_follow_serializer_save(factories):
follow = factories["federation.Follow"](approved=None)
factories["audio.Channel"](actor=follow.target)
data = {
"@context": jsonld.get_default_context(),
@ -352,8 +353,16 @@ def test_undo_follow_serializer_representation(factories):
assert serializer.data == expected
def test_undo_follow_serializer_save(factories):
follow = factories["federation.Follow"](approved=True)
@pytest.mark.parametrize(
(
"followed_name",
"follow_factory",
),
[("audio.Channel", "federation.Follow"), ("users.User", "federation.UserFollow")],
)
def test_undo_follow_serializer_save(factories, followed_name, follow_factory):
follow = factories[follow_factory](approved=True)
factories[followed_name](actor=follow.target)
data = {
"@context": jsonld.get_default_context(),
@ -366,9 +375,12 @@ def test_undo_follow_serializer_save(factories):
serializer = serializers.UndoFollowSerializer(data=data)
assert serializer.is_valid(raise_exception=True)
serializer.save()
with pytest.raises(models.Follow.DoesNotExist):
follow.refresh_from_db()
if followed_name == "audio.Channel":
with pytest.raises(models.Follow.DoesNotExist):
follow.refresh_from_db()
else:
with pytest.raises(models.UserFollow.DoesNotExist):
follow.refresh_from_db()
def test_undo_follow_serializer_validates_on_context(factories):

Wyświetl plik

@ -701,3 +701,34 @@ def test_check_all_remote_instance_skips_local(settings, factories, r_mock):
settings.FUNKWHALE_HOSTNAME = domain.name
tasks.check_all_remote_instance_availability()
assert not r_mock.called
def test_fetch_webfinger_create_actor(factories, r_mock, mocker):
actor = factories["federation.Actor"]()
fetch = factories["federation.Fetch"](url=f"webfinger://{actor.full_username}")
payload = serializers.ActorSerializer(actor).data
init = mocker.spy(serializers.ActorSerializer, "__init__")
save = mocker.spy(serializers.ActorSerializer, "save")
webfinger_payload = {
"subject": f"acct:{actor.full_username}",
"aliases": ["https://test.webfinger"],
"links": [
{"rel": "self", "type": "application/activity+json", "href": actor.fid}
],
}
webfinger_url = "https://{}/.well-known/webfinger?resource={}".format(
actor.domain_id, webfinger_payload["subject"]
)
r_mock.get(actor.fid, json=payload)
r_mock.get(webfinger_url, json=webfinger_payload)
tasks.fetch(fetch_id=fetch.pk)
fetch.refresh_from_db()
assert fetch.status == "finished"
assert fetch.object == actor
assert init.call_count == 1
assert init.call_args[0][1] == actor
assert init.call_args[1]["data"] == payload
assert save.call_count == 1

Wyświetl plik

@ -642,3 +642,35 @@ def test_index_libraries_page(factories, api_client, preferences):
assert response.status_code == 200
assert response.data == expected
def test_get_followers(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["federation.UserFollow"](target=actor, approved=True)
factories["federation.UserFollow"](target=actor, approved=True)
factories["federation.UserFollow"](target=actor, approved=True)
factories["federation.UserFollow"](target=actor, approved=True)
factories["federation.UserFollow"](target=actor, approved=True)
url = reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.data["totalItems"] == 5
def test_get_following(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["federation.UserFollow"](actor=actor, approved=True)
factories["federation.UserFollow"](actor=actor, approved=True)
factories["federation.UserFollow"](actor=actor, approved=True)
factories["federation.UserFollow"](actor=actor, approved=True)
factories["federation.UserFollow"](actor=actor, approved=True)
url = reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.data["totalItems"] == 5

Wyświetl plik

@ -1,11 +1,12 @@
from funkwhale_api.history import activities, serializers
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.federation.serializers import APIActorSerializer
def test_get_listening_activity_url(settings, factories):
listening = factories["history.Listening"]()
user_url = listening.user.get_activity_url()
user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=user.actor)
user_url = listening.actor.user.get_activity_url()
expected = f"{user_url}/listenings/tracks/{listening.pk}"
assert listening.get_activity_url() == expected
@ -13,7 +14,7 @@ def test_get_listening_activity_url(settings, factories):
def test_activity_listening_serializer(factories):
listening = factories["history.Listening"]()
actor = UserActivitySerializer(listening.user).data
actor = APIActorSerializer(listening.actor).data
field = serializers.serializers.DateTimeField()
expected = {
"type": "Listen",
@ -42,7 +43,8 @@ def test_track_listening_serializer_instance_activity_consumer(activity_registry
def test_broadcast_listening_to_instance_activity(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
listening = factories["history.Listening"]()
user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=user.actor)
data = serializers.ListeningActivitySerializer(listening).data
consumer = activities.broadcast_listening_to_instance_activity
message = {"type": "event.send", "text": "", "data": data}
@ -52,7 +54,8 @@ def test_broadcast_listening_to_instance_activity(factories, mocker):
def test_broadcast_listening_to_instance_activity_private(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
listening = factories["history.Listening"](user__privacy_level="me")
user = factories["users.User"](privacy_level="me", with_actor=True)
listening = factories["history.Listening"](actor__user=user)
data = serializers.ListeningActivitySerializer(listening).data
consumer = activities.broadcast_listening_to_instance_activity
consumer(data=data, obj=listening)

Wyświetl plik

@ -6,7 +6,7 @@ from funkwhale_api.history import models
def test_can_create_listening(factories):
track = factories["music.Track"]()
user = factories["users.User"]()
models.Listening.objects.create(user=user, track=track)
models.Listening.objects.create(actor=user.actor, track=track)
def test_logged_in_user_can_create_listening_via_api(
@ -20,7 +20,7 @@ def test_logged_in_user_can_create_listening_via_api(
listening = models.Listening.objects.latest("id")
assert listening.track == track
assert listening.user == logged_in_client.user
assert listening.actor.user == logged_in_client.user
def test_adding_listening_calls_activity_record(

Wyświetl plik

@ -6,14 +6,13 @@ from funkwhale_api.users import serializers as users_serializers
def test_listening_serializer(factories, to_api_date):
listening = factories["history.Listening"]()
actor = listening.user.create_actor()
actor = listening.actor
expected = {
"id": listening.pk,
"creation_date": to_api_date(listening.creation_date),
"track": music_serializers.TrackSerializer(listening.track).data,
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(listening.user).data,
}
serializer = serializers.ListeningSerializer(listening)

Wyświetl plik

@ -5,7 +5,8 @@ from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
factories["history.Listening"](user__privacy_level=level)
user = factories["users.User"](privacy_level=level)
factories["history.Listening"](actor__user=user)
url = reverse("api:v1:history:listenings-list")
response = api_client.get(url)
assert response.status_code == 200

Wyświetl plik

@ -49,10 +49,10 @@ def test_can_pick_by_weight():
def test_session_radio_excludes_previous_picks(factories):
tracks = factories["music.Track"].create_batch(5)
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
previous_choices = []
for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user)
TrackFavorite.add(track=random.choice(tracks), actor=user.actor)
radio = radios.SessionRadio()
radio.radio_type = "favorites"
@ -72,16 +72,16 @@ def test_session_radio_excludes_previous_picks(factories):
def test_can_get_choices_for_favorites_radio(factories):
files = factories["music.Upload"].create_batch(10)
tracks = [f.track for f in files]
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user)
TrackFavorite.add(track=random.choice(tracks), actor=user.actor)
radio = radios.FavoritesRadio()
choices = radio.get_choices(user=user)
assert choices.count() == user.track_favorites.all().count()
assert choices.count() == user.actor.track_favorites.all().count()
for favorite in user.track_favorites.all():
for favorite in user.actor.track_favorites.all():
assert favorite.track in choices
for i in range(5):
@ -324,10 +324,10 @@ def test_can_start_artist_radio_from_api(logged_in_api_client, preferences, fact
def test_can_start_less_listened_radio(factories):
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
wrong_files = factories["music.Upload"].create_batch(5)
for f in wrong_files:
factories["history.Listening"](track=f.track, user=user)
factories["history.Listening"](track=f.track, actor=user.actor)
good_files = factories["music.Upload"].create_batch(5)
good_tracks = [f.track for f in good_files]
radio = radios.LessListenedRadio()
@ -346,10 +346,11 @@ def test_similar_radio_track(factories):
factories["music.Track"].create_batch(5)
# one user listened to this track
l1 = factories["history.Listening"](track=seed)
l1user = factories["users.User"](with_actor=True)
l1 = factories["history.Listening"](track=seed, actor=l1user.actor)
expected_next = factories["music.Track"]()
factories["history.Listening"](track=expected_next, user=l1.user)
factories["history.Listening"](track=expected_next, actor=l1.actor)
assert radio.pick(filter_playable=False) == expected_next

Wyświetl plik

@ -77,9 +77,9 @@ def test_session_radio_excludes_previous_picks_v2(factories, logged_in_api_clien
def test_can_get_choices_for_favorites_radio_v2(factories):
files = factories["music.Upload"].create_batch(10)
tracks = [f.track for f in files]
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user)
TrackFavorite.add(track=random.choice(tracks), actor=user.actor)
radio = radios_v2.FavoritesRadio()
session = radio.start_session(user=user)
@ -87,9 +87,9 @@ def test_can_get_choices_for_favorites_radio_v2(factories):
quantity=100, filter_playable=False
)
assert len(choices) == user.track_favorites.all().count()
assert len(choices) == user.actor.track_favorites.all().count()
for favorite in user.track_favorites.all():
for favorite in user.actor.track_favorites.all():
assert favorite.track in choices

Wyświetl plik

@ -308,7 +308,7 @@ def test_playlist_detail_serializer(factories):
def test_scrobble_serializer(factories):
upload = factories["music.Upload"]()
track = upload.track
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
payload = {"id": track.pk, "submission": True}
serializer = serializers.ScrobbleSerializer(data=payload, context={"user": user})
@ -316,7 +316,7 @@ def test_scrobble_serializer(factories):
listening = serializer.save()
assert listening.user == user
assert listening.actor.user == user
assert listening.track == track

Wyświetl plik

@ -339,6 +339,7 @@ def test_stream_transcode(
@pytest.mark.parametrize("f", ["json"])
def test_star(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-star")
assert url.endswith("star") is True
track = factories["music.Track"]()
@ -347,30 +348,34 @@ def test_star(f, db, logged_in_api_client, factories):
assert response.status_code == 200
assert response.data == {"status": "ok"}
favorite = logged_in_api_client.user.track_favorites.latest("id")
favorite = logged_in_api_client.user.actor.track_favorites.latest("id")
assert favorite.track == track
@pytest.mark.parametrize("f", ["json"])
def test_unstar(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-unstar")
assert url.endswith("unstar") is True
track = factories["music.Track"]()
factories["favorites.TrackFavorite"](track=track, user=logged_in_api_client.user)
factories["favorites.TrackFavorite"](
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
assert response.status_code == 200
assert response.data == {"status": "ok"}
assert logged_in_api_client.user.track_favorites.count() == 0
assert logged_in_api_client.user.actor.track_favorites.count() == 0
@pytest.mark.parametrize("f", ["json"])
def test_get_starred2(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_starred2")
assert url.endswith("getStarred2") is True
track = factories["music.Track"]()
favorite = factories["favorites.TrackFavorite"](
track=track, user=logged_in_api_client.user
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
@ -427,11 +432,12 @@ def test_get_genres(f, db, logged_in_api_client, factories, mocker):
@pytest.mark.parametrize("f", ["json"])
def test_get_starred(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_starred")
assert url.endswith("getStarred") is True
track = factories["music.Track"]()
favorite = factories["favorites.TrackFavorite"](
track=track, user=logged_in_api_client.user
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
@ -832,6 +838,7 @@ def test_get_avatar(factories, logged_in_api_client):
def test_scrobble(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
upload = factories["music.Upload"]()
track = upload.track
url = reverse("api:subsonic:subsonic-scrobble")
@ -840,7 +847,7 @@ def test_scrobble(factories, logged_in_api_client):
assert response.status_code == 200
listening = logged_in_api_client.user.listenings.latest("id")
listening = logged_in_api_client.user.actor.listenings.latest("id")
assert listening.track == track

Wyświetl plik

@ -540,3 +540,18 @@ def test_user_change_email(logged_in_api_client, mocker, mailoutbox):
assert address.verified is False
assert response.status_code == 204
assert len(mailoutbox) == 1
# to do :
# def test_user_changing_privacy_level_dispatch_delete_activity(
# logged_in_api_client, mocker
# ):
# user = logged_in_api_client.user
# payload = {"privacy_level": "me"}
# url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
# # mocker.patch("funkwhale_api.users.views.")
# response = logged_in_api_client.patch(url, payload)
# assert response.status_code == 200
# user.refresh_from_db()
# assert user.privacy_level == "me"

Wyświetl plik

@ -0,0 +1,170 @@
# to do : to delete
# import pytest
# from django.test import Client
# from django.urls import reverse
# from funkwhale_api.common import serializers as common_serializers
# from funkwhale_api.common import utils as common_utils
# from funkwhale_api.moderation import tasks as moderation_tasks
# from funkwhale_api.users.models import User
# def test_can_follow_user(factories, logged_in_api_client, mocker):
# followed_user = factories["users.User"]()
# actor = factories["federation.Actor"]()
# logged_in_api_client.user.actor = actor
# url = reverse("api:v2:users:users-follow-requests", kwargs={"pk": followed_user.pk})
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# response = logged_in_api_client.post(url)
# assert response.status_code == 204
# assert routes.call_count == 1
# def test_can_unfollow(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# followed_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# target=followed_user, actor=logged_in_api_client.user.actor
# )
# url = reverse("api:v2:users:users-unfollow", kwargs={"pk": followed_user.pk})
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# response = logged_in_api_client.post(url)
# assert response.status_code == 200
# # /users/id/follow_requests/
# # def test_can_patch_follow_user(factories, logged_in_api_client, mocker):
# # logged_in_api_client.user.create_actor()
# # following_user = factories["users.User"](with_actor=True)
# # url = reverse(
# # "api:v2:users:users-follow-requests",
# # kwargs={"pk": logged_in_api_client.user.pk},
# # )
# # routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# # data = {
# # "approved": True,
# # "actor": following_user.actor,
# # "target": logged_in_api_client.user.pk,
# # }
# # response = logged_in_api_client.patch(url, data=data)
# # assert response.status_code == 204
# # assert routes.call_count == 1
# def test_can_patch_follow_user_v2(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# target=following_user, actor=following_user.actor
# )
# url = reverse(
# "api:v2:users:follow_request_patch",
# kwargs={
# "user_pk": logged_in_api_client.user.pk,
# "follow_pk": user_follow.pk,
# },
# )
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# data = {
# "approved": True,
# }
# response = logged_in_api_client.patch(url, data=data, format="json")
# assert response.status_code == 204
# assert routes.call_count == 1
# # def test_only_target_user_can_patch_follow_user(factories, logged_in_api_client):
# # logged_in_api_client.user.create_actor()
# # followed_user = factories["users.User"]()
# # url = reverse("api:v2:users:users-follow-requests", kwargs={"pk": followed_user.pk})
# # data = {
# # "approved": True,
# # "actor": logged_in_api_client.user.actor,
# # "target": followed_user.pk,
# # }
# # response = logged_in_api_client.patch(url, data=data)
# # assert response.status_code == 403
# def test_can_get_my_userfollowings(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# followed_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# actor=logged_in_api_client.user.actor, target=followed_user
# )
# url = reverse(
# "api:v2:users:users-followings", kwargs={"pk": logged_in_api_client.user.pk}
# )
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# assert str(user_follow.uuid) == response.data["results"][0]["uuid"]
# def test_can_get_user_public_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True, privacy_level="public")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# assert str(user_follow.uuid) == response.data["results"][0]["uuid"]
# def test_cannot_get_user_private_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True, privacy_level="private")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 403
# def test_can_get_user_pod_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True, privacy_level="pod")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# def test_cannot_get_user_pod_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# factories["federation.Domain"](name="notatalllocal")
# logged_in_api_client.user.create_actor(domain_id="notatalllocal")
# following_user = factories["users.User"](with_actor=True, privacy_level="pod")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 403
# def test_can_get_my_followers(factories, logged_in_api_client, mocker):
# following_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# target=logged_in_api_client.user, actor=following_user.actor
# )
# url = reverse(
# "api:v2:users:users-followers", kwargs={"pk": logged_in_api_client.user.pk}
# )
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# assert str(user_follow.uuid) == response.data["results"][0]["uuid"]
# # to do : do this through should_autoapprove_follow autoapprove = serializer.validated_data["object"].should_autoapprove_follow(
# def test_follow_public_user_autoapprove(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# followed_user = factories["users.User"](with_actor=True, privacy_level="public")
# url = reverse("api:v2:users:users-follow-requests", kwargs={"pk": followed_user.pk})
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# response = logged_in_api_client.post(url)
# assert response.status_code == 204
# assert routes.call_count == 1

Wyświetl plik

@ -139,6 +139,8 @@ services:
- "./front:/frontend:ro"
- "./data/staticfiles:/staticfiles:ro"
- "./data/media:/protected/media:ro"
- "./data/media:/data/media:ro"
networks:
- federation
- internal

Wyświetl plik

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Notification, LibraryFollow } from '~/types'
import type { Notification, LibraryFollow, UserFollow } from '~/types'
import { computed, ref, watchEffect, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@ -61,6 +61,38 @@ const notificationData = computed(() => {
message: t('components.notifications.NotificationRow.message.libraryReject', { username: username.value, library: activity.object.name })
}
}
if (activity.object && activity.object.type === 'federation.Actor') {
const detailUrl = { name: 'profile.full', params: { username: activity.actor.preferred_username, domain: activity.actor.domain } }
if (activity.related_object?.approved === null) {
return {
detailUrl,
message: t('components.notifications.NotificationRow.message.userPendingFollow', { username: username.value, user: activity.object.full_username }),
acceptFollow: {
buttonClass: 'success',
icon: 'check',
label: t('components.notifications.NotificationRow.button.approve'),
handler: () => approveUserFollow(activity.related_object)
},
rejectFollow: {
buttonClass: 'danger',
icon: 'x',
label: t('components.notifications.NotificationRow.button.reject'),
handler: () => rejectUserFollow(activity.related_object)
}
}
} else if (activity.related_object?.approved) {
return {
detailUrl,
message: t('components.notifications.NotificationRow.message.userFollow', { username: username.value, user: activity.actor.full_username })
}
}
return {
detailUrl,
message: t('components.notifications.NotificationRow.message.userReject', { username: username.value, user: activity.actor.full_username })
}
}
}
if (activity.type === 'Accept') {
@ -70,6 +102,12 @@ const notificationData = computed(() => {
message: t('components.notifications.NotificationRow.message.libraryAcceptFollow', { username: username.value, library: activity.related_object.name })
}
}
if (activity.object?.type === 'federation.Actor') {
return {
detailUrl: { name: 'content.remote.index' },
message: t('components.notifications.NotificationRow.message.userAcceptFollow', { username: username.value, user: activity.actor.full_username })
}
}
}
return {}
@ -100,6 +138,18 @@ const rejectLibraryFollow = async (follow: LibraryFollow) => {
follow.approved = false
item.value.is_read = true
}
const approveUserFollow = async (follow: UserFollow) => {
await axios.post(`federation/follows/user/${follow.uuid}/accept/`)
follow.approved = true
item.value.is_read = true
}
const rejectUserFollow = async (follow: UserFollow) => {
await axios.post(`federation/follows/user/${follow.uuid}/reject/`)
follow.approved = false
item.value.is_read = true
}
</script>
<template>

Wyświetl plik

@ -2781,7 +2781,11 @@
"libraryAcceptFollow": "{username} accepted your follow on library \"{library}\"",
"libraryFollow": "{username} followed your library \"{library}\"",
"libraryPendingFollow": "{username} wants to follow your library \"{library}\"",
"libraryReject": "You rejected {username}'s request to follow \"{library}\""
"libraryReject": "You rejected {username}'s request to follow \"{library}\"",
"userAcceptFollow": "{username} accepted your follow",
"userFollow": "{username} followed you",
"userPendingFollow": "{username} wants to follow you",
"userReject": "You rejected {username}'s request to follow you"
}
}
},

Wyświetl plik

@ -2781,7 +2781,11 @@
"libraryAcceptFollow": "{username} accepted your follow on library \"{library}\"",
"libraryFollow": "{username} followed your library \"{library}\"",
"libraryPendingFollow": "{username} wants to follow your library \"{library}\"",
"libraryReject": "You rejected {username}'s request to follow \"{library}\""
"libraryReject": "You rejected {username}'s request to follow \"{library}\"",
"userAcceptFollow": "{username} accepted your follow",
"userFollow": "{username} followed you",
"userPendingFollow": "{username} wants to follow you",
"userReject": "You rejected {username}'s request to follow you"
}
}
},

Wyświetl plik

@ -164,6 +164,16 @@ export interface LibraryFollow {
target: Library
}
// to do : can't get Activity typescript to accept library follow and user follow
export interface UserFollow {
uuid: string
approved: boolean
name: string
type?: 'federation.Actor' | 'federation.UserFollow'
target?: Actor
}
export interface Cover {
uuid: string
urls: {
@ -474,10 +484,17 @@ export interface UserRequest {
export type Activity = {
actor: Actor
creation_date: string
related_object: LibraryFollow
related_object: UserFollow
type: 'Follow' | 'Accept'
object: LibraryFollow
object: UserFollow
}
export type UserFollowActivity = {
actor: Actor
creation_date: string
related_object: UserFollow
type: 'Follow' | 'Accept'
object: UserFollow
}
export interface Notification {
id: number