kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
userfollow and favorite listening activities
rodzic
4bef27552f
commit
878cb32b96
|
@ -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"))]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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"]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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})
|
||||
|
|
|
@ -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)
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
2
dev.yml
2
dev.yml
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
Ładowanie…
Reference in New Issue