Porównaj commity

...

26 Commity

Autor SHA1 Wiadomość Data
petitminion 67b5392c30 Merge branch '1810-user-follow' into 'develop'
draft : User follow with trackfavorite activity

Closes #1810

See merge request funkwhale/funkwhale!2774
2024-04-20 12:30:13 +00:00
Petitminion 18b4c6e5f3 resolve federation issue (lacking favorite fid) 2024-04-20 14:26:56 +02:00
Petitminion 890963db30 migrate privavy_level to actor, replace "favorite" by "like", delete apiv2 endpoints 2024-04-20 13:15:17 +02:00
Petitminion 878cb32b96 userfollow and favorite listening activities 2024-04-17 18:18:14 +02:00
Petitminion 4bef27552f upgrade docker postgres dev version to postgres15
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2771>
2024-04-16 13:04:32 +00:00
Ciarán Ainsworth ec368e0cd3 Update from attribute information
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth a2579bdc60 Add from attribute to genre tag spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth e1e0045a23 Add changelog fragment
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 85c2be6a5b fix(docs): run pre-commit
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 35de9bd48e feat(docs): add genre tags spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Petitminion ba5b657b61 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 4fc73c1430 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 97e24bcaa6 Apply 12 suggestion(s) to 4 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 1b15fea1ab Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth b624fea2fa Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth e028e8788b Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 67f74d40a6 Add ListenBrainz sync documentation
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 547bd6f371 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 05ec6f6d0f tests
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion a03cc1db24 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 2a364d5785 add favorite sync
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 5bc0171694 delete test
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 37acfa475d loads of things
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion f45fd1e465 various reviews
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 17c4a92f77 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 6414302899 implement listening and favorite sync with listenbrainz
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
95 zmienionych plików z 2737 dodań i 231 usunięć

Wyświetl plik

@ -18,6 +18,6 @@ MEDIA_ROOT=/data/media
# FORCE_HTTPS_URLS=True
# Customize to your needs
POSTGRES_VERSION=11
POSTGRES_VERSION=15
DEBUG=true
TYPESENSE_API_KEY="apikey"

Wyświetl plik

@ -303,6 +303,23 @@ LISTENING_CREATED = "listening_created"
"""
Called when a track is being listened
"""
LISTENING_SYNC = "listening_sync"
"""
Called by the task manager to trigger listening sync
"""
FAVORITE_CREATED = "favorite_created"
"""
Called when a track is being favorited
"""
FAVORITE_DELETED = "favorite_deleted"
"""
Called when a favorited track is being unfavorited
"""
FAVORITE_SYNC = "favorite_sync"
"""
Called by the task manager to trigger favorite sync
"""
SCAN = "scan"
"""

Wyświetl plik

@ -276,6 +276,7 @@ LOCAL_APPS = (
# Your stuff: custom apps go here
"funkwhale_api.instance",
"funkwhale_api.audio",
"funkwhale_api.contrib.listenbrainz",
"funkwhale_api.music",
"funkwhale_api.requests",
"funkwhale_api.favorites",
@ -949,6 +950,16 @@ CELERY_BEAT_SCHEDULE = {
),
"options": {"expires": 60 * 60},
},
"listenbrainz.trigger_listening_sync_with_listenbrainz": {
"task": "listenbrainz.trigger_listening_sync_with_listenbrainz",
"schedule": crontab(day_of_week="*", minute="0", hour="3"),
"options": {"expires": 60 * 60 * 24},
},
"listenbrainz.trigger_favorite_sync_with_listenbrainz": {
"task": "listenbrainz.trigger_favorite_sync_with_listenbrainz",
"schedule": crontab(day_of_week="*", minute="0", hour="3"),
"options": {"expires": 60 * 60 * 24},
},
}
if env.str("TYPESENSE_API_KEY", default=None):

Wyświetl plik

@ -38,13 +38,13 @@ def combined_recent(limit, **kwargs):
def get_activity(user, limit=20):
query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
query = fields.privacy_level_query(user, "actor__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)

Wyświetl plik

@ -24,8 +24,20 @@ def privacy_level_query(user, lookup_field="privacy_level", user_field="user"):
if user.is_anonymous:
return models.Q(**{lookup_field: "everyone"})
return models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]}) | models.Q(
**{lookup_field: "me", user_field: user}
actors_follows = user.actor.user_follows.filter(approved=True).values_list(
"target", flat=True
)
followers_query = models.Q(
**{
f"{lookup_field}": "followers",
f"{user_field}__actor__pk__in": actors_follows,
}
)
return (
models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]})
| models.Q(**{lookup_field: "me", user_field: user})
| followers_query
)

Wyświetl plik

@ -56,3 +56,43 @@ 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.actor.privacy_level == "everyone":
return True
# user is anonymous
elif not hasattr(request.user, "actor"):
return False
elif obj.user.actor.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.actor.privacy_level == "me"
and obj.user.actor == request.user.actor
):
return True
elif (
obj.user.actor.privacy_level == "followers"
and request.user.actor in obj.user.actor.get_followers()
):
return True
else:
return False

Wyświetl plik

@ -1,28 +1,31 @@
import liblistenbrainz
from django.utils import timezone
import funkwhale_api
from config import plugins
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
from . import tasks
from .funkwhale_startup import PLUGIN
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
def submit_listen(listening, conf, **kwargs):
user_token = conf["user_token"]
if not user_token:
if not user_token and not conf["submit_listenings"]:
return
logger = PLUGIN["logger"]
logger.info("Submitting listen to ListenBrainz")
client = liblistenbrainz.ListenBrainz()
client.set_auth_token(user_token)
listen = get_listen(listening.track)
listen = get_lb_listen(listening)
client.submit_single_listen(listen)
def get_listen(track):
def get_lb_listen(listening):
track = listening.track
additional_info = {
"media_player": "Funkwhale",
"media_player_version": funkwhale_api.__version__,
@ -51,7 +54,83 @@ def get_listen(track):
return liblistenbrainz.Listen(
track_name=track.title,
artist_name=track.artist.name,
listened_at=int(timezone.now()),
listened_at=listening.creation_date.timestamp(),
release_name=release_name,
additional_info=additional_info,
)
@plugins.register_hook(plugins.FAVORITE_CREATED, PLUGIN)
def submit_favorite_creation(track_favorite, conf, **kwargs):
user_token = conf["user_token"]
if not user_token or not conf["submit_favorites"]:
return
logger = PLUGIN["logger"]
logger.info("Submitting favorite to ListenBrainz")
client = liblistenbrainz.ListenBrainz()
track = track_favorite.track
if not track.mbid:
logger.warning(
"This tracks doesn't have a mbid. Feedback will not be submitted to Listenbrainz"
)
return
client.submit_user_feedback(1, track.mbid)
@plugins.register_hook(plugins.FAVORITE_DELETED, PLUGIN)
def submit_favorite_deletion(track_favorite, conf, **kwargs):
user_token = conf["user_token"]
if not user_token or not conf["submit_favorites"]:
return
logger = PLUGIN["logger"]
logger.info("Submitting favorite deletion to ListenBrainz")
client = liblistenbrainz.ListenBrainz()
track = track_favorite.track
if not track.mbid:
logger.warning(
"This tracks doesn't have a mbid. Feedback will not be submitted to Listenbrainz"
)
return
client.submit_user_feedback(0, track.mbid)
@plugins.register_hook(plugins.LISTENING_SYNC, PLUGIN)
def sync_listenings_from_listenbrainz(user, conf):
user_name = conf["user_name"]
if not user_name or not conf["sync_listenings"]:
return
logger = PLUGIN["logger"]
logger.info("Getting listenings from ListenBrainz")
try:
last_ts = (
history_models.Listening.objects.filter(actor=user.actor)
.filter(source="Listenbrainz")
.latest("creation_date")
.values_list("creation_date", flat=True)
).timestamp()
except funkwhale_api.history.models.Listening.DoesNotExist:
tasks.import_listenbrainz_listenings(user, user_name, 0)
return
tasks.import_listenbrainz_listenings(user, user_name, last_ts)
@plugins.register_hook(plugins.FAVORITE_SYNC, PLUGIN)
def sync_favorites_from_listenbrainz(user, conf):
user_name = conf["user_name"]
if not user_name or not conf["sync_favorites"]:
return
try:
last_ts = (
favorites_models.TrackFavorite.objects.filter(actor=user.actor)
.filter(source="Listenbrainz")
.latest("creation_date")
.creation_date.timestamp()
)
except favorites_models.TrackFavorite.DoesNotExist:
tasks.import_listenbrainz_favorites(user, user_name, 0)
return
tasks.import_listenbrainz_favorites(user, user_name, last_ts)

Wyświetl plik

@ -3,7 +3,7 @@ from config import plugins
PLUGIN = plugins.get_plugin_config(
name="listenbrainz",
label="ListenBrainz",
description="A plugin that allows you to submit your listens to ListenBrainz.",
description="A plugin that allows you to submit or sync your listens and favorites to ListenBrainz.",
homepage="https://docs.funkwhale.audio/users/builtinplugins.html#listenbrainz-plugin", # noqa
version="0.3",
user=True,
@ -13,6 +13,45 @@ PLUGIN = plugins.get_plugin_config(
"type": "text",
"label": "Your ListenBrainz user token",
"help": "You can find your user token in your ListenBrainz profile at https://listenbrainz.org/profile/",
}
},
{
"name": "user_name",
"type": "text",
"required": False,
"label": "Your ListenBrainz user name.",
"help": "Required for importing listenings and favorites with ListenBrainz \
but not to send activities",
},
{
"name": "submit_listenings",
"type": "boolean",
"default": True,
"label": "Enable listening submission to ListenBrainz",
"help": "If enabled, your listenings from Funkwhale will be imported into ListenBrainz.",
},
{
"name": "sync_listenings",
"type": "boolean",
"default": False,
"label": "Enable listenings sync",
"help": "If enabled, your listening from ListenBrainz will be imported into Funkwhale. This means they \
will be used along with Funkwhale listenings to filter out recently listened content or \
generate recommendations",
},
{
"name": "sync_favorites",
"type": "boolean",
"default": False,
"label": "Enable favorite sync",
"help": "If enabled, your favorites from ListenBrainz will be imported into Funkwhale. This means they \
will be used along with Funkwhale favorites (UI display, federation activity)",
},
{
"name": "submit_favorites",
"type": "boolean",
"default": False,
"label": "Enable favorite submission to ListenBrainz services",
"help": "If enabled, your favorites from Funkwhale will be submitted to ListenBrainz",
},
],
)

Wyświetl plik

@ -0,0 +1,165 @@
import datetime
import liblistenbrainz
from django.utils import timezone
from config import plugins
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from funkwhale_api.taskapp import celery
from funkwhale_api.users import models
from .funkwhale_startup import PLUGIN
@celery.app.task(name="listenbrainz.trigger_listening_sync_with_listenbrainz")
def trigger_listening_sync_with_listenbrainz():
now = timezone.now()
active_month = now - datetime.timedelta(days=30)
users = (
models.User.objects.filter(plugins__code="listenbrainz")
.filter(plugins__conf__sync_listenings=True)
.filter(last_activity__gte=active_month)
)
for user in users:
plugins.trigger_hook(
plugins.LISTENING_SYNC,
user=user,
confs=plugins.get_confs(user),
)
@celery.app.task(name="listenbrainz.trigger_favorite_sync_with_listenbrainz")
def trigger_favorite_sync_with_listenbrainz():
now = timezone.now()
active_month = now - datetime.timedelta(days=30)
users = (
models.User.objects.filter(plugins__code="listenbrainz")
.filter(plugins__conf__sync_listenings=True)
.filter(last_activity__gte=active_month)
)
for user in users:
plugins.trigger_hook(
plugins.FAVORITE_SYNC,
user=user,
confs=plugins.get_confs(user),
)
@celery.app.task(name="listenbrainz.import_listenbrainz_listenings")
def import_listenbrainz_listenings(user, user_name, since):
client = liblistenbrainz.ListenBrainz()
response = client.get_listens(username=user_name, min_ts=since, count=100)
listens = response["payload"]["listens"]
while listens:
add_lb_listenings_to_db(listens, user)
new_ts = max(
listens,
key=lambda obj: datetime.datetime.fromtimestamp(
obj.listened_at, timezone.utc
),
)
response = client.get_listens(username=user_name, min_ts=new_ts, count=100)
listens = response["payload"]["listens"]
def add_lb_listenings_to_db(listens, user):
logger = PLUGIN["logger"]
fw_listens = []
for listen in listens:
if (
listen.additional_info.get("submission_client")
and listen.additional_info.get("submission_client")
== "Funkwhale ListenBrainz plugin"
and history_models.Listening.objects.filter(
creation_date=datetime.datetime.fromtimestamp(
listen.listened_at, timezone.utc
)
).exists()
):
logger.info(
f"Listen with ts {listen.listened_at} skipped because already in db"
)
continue
mbid = (
listen.mbid_mapping
if hasattr(listen, "mbid_mapping")
else listen.recording_mbid
)
if not mbid:
logger.info("Received listening that doesn't have a mbid. Skipping...")
try:
track = music_models.Track.objects.get(mbid=mbid)
except music_models.Track.DoesNotExist:
logger.info(
"Received listening that doesn't exist in fw database. Skipping..."
)
continue
user = user
fw_listen = history_models.Listening(
creation_date=datetime.datetime.fromtimestamp(
listen.listened_at, timezone.utc
),
track=track,
actor=user.actor,
source="Listenbrainz",
)
fw_listens.append(fw_listen)
history_models.Listening.objects.bulk_create(fw_listens)
@celery.app.task(name="listenbrainz.import_listenbrainz_favorites")
def import_listenbrainz_favorites(user, user_name, since):
client = liblistenbrainz.ListenBrainz()
response = client.get_user_feedback(username=user_name)
offset = 0
while response["feedback"]:
count = response["count"]
offset = offset + count
last_sync = min(
response["feedback"],
key=lambda obj: datetime.datetime.fromtimestamp(
obj["created"], timezone.utc
),
)["created"]
add_lb_feedback_to_db(response["feedback"], user)
if last_sync <= since or count == 0:
return
response = client.get_user_feedback(username=user_name, offset=offset)
def add_lb_feedback_to_db(feedbacks, user):
logger = PLUGIN["logger"]
for feedback in feedbacks:
try:
track = music_models.Track.objects.get(mbid=feedback["recording_mbid"])
except music_models.Track.DoesNotExist:
logger.info(
"Received feedback track that doesn't exist in fw database. Skipping..."
)
continue
if feedback["score"] == 1:
favorites_models.TrackFavorite.objects.get_or_create(
actor=user.actor,
creation_date=datetime.datetime.fromtimestamp(
feedback["created"], timezone.utc
),
track=track,
source="Listenbrainz",
)
elif feedback["score"] == 0:
try:
favorites_models.TrackFavorite.objects.get(
actor=user.actor, track=track
).delete()
except favorites_models.TrackFavorite.DoesNotExist:
continue
elif feedback["score"] == -1:
logger.info("Funkwhale doesn't support disliked tracks")

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-12-09 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('favorites', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='trackfavorite',
name='source',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,26 +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__privacy_level="everyone")
if hasattr(actor, "user"):
me_query = models.Q(actor__privacy_level="me", actor=actor)
me_query = models.Q(actor__privacy_level="me", actor=actor)
instance_query = models.Q(
actor__privacy_level="instance", actor__domain=actor.domain
)
instance_actor_query = models.Q(
actor__privacy_level="instance", actor__domain=actor.domain
)
return self.filter(
me_query
| instance_query
| instance_actor_query
| models.Q(actor__privacy_level="everyone")
)
class TrackFavorite(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(
"users.User", related_name="track_favorites", on_delete=models.CASCADE
actor = models.ForeignKey(
"federation.Actor",
related_name="track_favorites",
on_delete=models.CASCADE,
null=True,
blank=True,
)
track = models.ForeignKey(
Track, related_name="track_favorites", on_delete=models.CASCADE
)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "likes"
objects = TrackFavoriteQuerySet.as_manager()
class Meta:
unique_together = ("track", "user")
unique_together = ("track", "actor")
ordering = ("-creation_date",)
@classmethod
def add(cls, track, user):
favorite, created = cls.objects.get_or_create(user=user, track=track)
def add(cls, track, actor):
favorite, created = cls.objects.get_or_create(actor=actor, track=track)
return favorite
def get_activity_url(self):
return f"{self.user.get_activity_url()}/favorites/tracks/{self.pk}"
return f"{self.actor.get_absolute_url()}/favorites/tracks/{self.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

Wyświetl plik

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

Wyświetl plik

@ -4,8 +4,10 @@ from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
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
@ -22,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,
@ -31,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"]:
@ -44,7 +47,16 @@ class TrackFavoriteViewSet(
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
plugins.trigger_hook(
plugins.FAVORITE_CREATED,
track_favorite=serializer.instance,
confs=plugins.get_confs(self.request.user),
)
record.send(instance)
routes.outbox.dispatch(
{"type": "Like", "object": {"type": "Track"}},
context={"favorite": instance},
)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
@ -52,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__privacy_level", "actor__user"
)
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
@ -64,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")
@ -72,10 +86,19 @@ 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": "Like"}},
context={"favorite": favorite},
)
favorite.delete()
plugins.trigger_hook(
plugins.FAVORITE_DELETED,
track_favorite=favorite,
confs=plugins.get_confs(self.request.user),
)
return Response([], status=status.HTTP_204_NO_CONTENT)
@extend_schema(
@ -92,7 +115,9 @@ class TrackFavoriteViewSet(
if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=401)
favorites = request.user.track_favorites.values("id", "track").order_by("id")
favorites = request.user.actor.track_favorites.values("id", "track").order_by(
"id"
)
payload = serializers.AllFavoriteSerializer(favorites).data
return Response(payload, status=200)

Wyświetl plik

@ -119,6 +119,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 +226,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 +311,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 +453,18 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
elif r == PUBLIC_ADDRESS:
urls.append(r)
elif isinstance(r, dict) and r["type"] == "followers":
# to do : rename user_received_follows to received_follows ? Could clash with Follow model
received_follows = (
r["target"]
.received_follows.filter(approved=True)
.select_related("actor__user")
)
if not received_follows and hasattr(r["target"], "received_user_follows"):
received_follows = (
r["target"]
.received_user_follows.filter(approved=True)
.select_related("actor__user")
)
for follow in received_follows:
actor = follow.actor
if actor.is_local:

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,34 @@
# Generated by Django 4.2.9 on 2024-04-17 19:15
from django.db import migrations, models
def gen_privacy_level(apps, schema_editor):
user_model = apps.get_model("users", "User")
for user in user_model.objects.all():
user.actor.privacy_level = user.actor.privacy_level
user.actor.save(update_fields=["privacy_level"])
class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
]
operations = [
migrations.AddField(
model_name="actor",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
default="instance",
max_length=30,
),
),
migrations.RunPython(gen_privacy_level, reverse_code=migrations.RunPython.noop),
]

Wyświetl plik

@ -14,7 +14,7 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import session
from funkwhale_api.common import session, fields
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import validators as common_validators
from funkwhale_api.music import utils as music_utils
@ -218,6 +218,7 @@ class Actor(models.Model):
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
privacy_level = fields.get_privacy_field()
objects = ActorQuerySet.as_manager()
@ -254,6 +255,8 @@ class Actor(models.Model):
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.actor.privacy_level == "public":
return True
return False
def get_user(self):
@ -638,3 +641,15 @@ def update_denormalization_follow_deleted(sender, instance, **kwargs):
music_models.TrackActor.objects.filter(
actor=instance.actor, upload__in=instance.target.uploads.all()
).delete()
class UserFollow(AbstractFollow):
actor = models.ForeignKey(
Actor, related_name="user_follows", on_delete=models.CASCADE
)
target = models.ForeignKey(
Actor, related_name="received_user_follows", on_delete=models.CASCADE
)
class Meta:
unique_together = ["actor", "target"]

Wyświetl plik

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

Wyświetl plik

@ -12,6 +12,7 @@ from rest_framework import serializers
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
@ -20,7 +21,7 @@ from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__)
@ -644,9 +645,14 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs):
target = self.validated_data["object"]
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
elif (
target._meta.label == "federation.Actor"
and target.type == "Person"
and not target.get_channel()
):
follow_class = models.UserFollow
else:
follow_class = models.Follow
defaults = kwargs
@ -723,6 +729,10 @@ class FollowActionSerializer(serializers.Serializer):
if target._meta.label == "music.Library":
expected = target.actor
follow_class = models.LibraryFollow
# to do : what if the follow is an AP follow of an non fw object ?
elif target._meta.label == "federation.Actor" and not target.get_channel():
expected = target
follow_class = models.UserFollow
else:
expected = target
follow_class = models.Follow
@ -804,6 +814,8 @@ class UndoFollowSerializer(serializers.Serializer):
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
elif target._meta.label == "federation.Actor" and not target.get_channel():
follow_class = models.UserFollow
else:
follow_class = models.Follow
@ -812,7 +824,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target
).get()
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove")
raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data
def to_representation(self, instance):
@ -879,7 +893,6 @@ class ActivitySerializer(serializers.Serializer):
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
@ -2076,3 +2089,42 @@ 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)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
}
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["object"],
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,
)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -7,9 +7,9 @@ from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("user__username")
domain = django_filters.CharFilter("user__actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta:
model = models.Listening

Wyświetl plik

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-12-09 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('history', '0002_auto_20180325_1433'),
]
operations = [
migrations.AddField(
model_name='listening',
name='source',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,25 +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 ListeningQuerySet(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 = ListeningQuerySet.as_manager()
class Meta:
ordering = ("-creation_date",)
def get_activity_url(self):
return f"{self.user.get_activity_url()}/listenings/tracks/{self.pk}"
return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

Wyświetl plik

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

Wyświetl plik

@ -18,9 +18,7 @@ class ListeningViewSet(
viewsets.GenericViewSet,
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related(
"user__actor__attachment_icon"
)
queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
permission_classes = [
oauth_permissions.ScopePermission,
@ -29,6 +27,7 @@ class ListeningViewSet(
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter
def get_serializer_class(self):
@ -49,7 +48,9 @@ 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__privacy_level", "actor__user"
)
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)

Wyświetl plik

@ -295,7 +295,7 @@ class ManageActorFilterSet(filters.FilterSet):
class Meta:
model = federation_models.Actor
fields = ["domain", "type", "manually_approves_followers"]
fields = ["domain", "type", "manually_approves_followers", "privacy_level"]
def filter_local(self, queryset, name, value):
return queryset.local(value)
@ -316,7 +316,6 @@ class ManageUserFilterSet(filters.FilterSet):
model = users_models.User
fields = [
"is_active",
"privacy_level",
"is_staff",
"is_superuser",
"permission_library",

Wyświetl plik

@ -43,7 +43,6 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer):
"is_superuser",
"date_joined",
"last_activity",
"privacy_level",
"upload_quota",
)
@ -67,7 +66,6 @@ class ManageUserSerializer(serializers.ModelSerializer):
"date_joined",
"last_activity",
"permissions",
"privacy_level",
"upload_quota",
"full_username",
)
@ -224,6 +222,7 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
"shared_inbox_url",
"manually_approves_followers",
"is_local",
"privacy_level",
]
read_only_fields = ["creation_date", "instance_policy"]

Wyświetl plik

@ -94,7 +94,7 @@ class PlaylistViewSet(
return serializer.save(
user=self.request.user,
privacy_level=serializer.validated_data.get(
"privacy_level", self.request.user.privacy_level
"privacy_level", self.request.user.actor.privacy_level
),
)

Wyświetl plik

@ -148,7 +148,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
track_ids = (
kwargs["user"].actor.track_favorites.all().values_list("track", flat=True)
)
return qs.filter(pk__in=track_ids, artist__content_category="music")
@ -290,17 +292,17 @@ class SimilarRadio(RelatedObjectRadio):
FROM (
SELECT
track_id,
creation_date,
h.creation_date,
LEAD(track_id) OVER (
PARTITION by user_id order by creation_date asc
PARTITION by actor_id ORDER BY h.creation_date ASC
) AS next
FROM history_listening
INNER JOIN users_user ON (users_user.id = user_id)
WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' OR user_id = %s
ORDER BY creation_date ASC
) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC;
FROM history_listening h
INNER JOIN federation_actor fa ON (fa.id = h.actor_id)
WHERE fa.privacy_level = 'instance' OR fa.privacy_level = 'everyone' OR h.actor_id = %s
ORDER BY h.creation_date ASC
) t WHERE track_id = %s AND next IS NOT NULL AND next != %s GROUP BY next ORDER BY c DESC;
"""
cursor.execute(query, [self.session.user_id, seed, seed])
cursor.execute(query, [self.session.user.actor, seed, seed])
next_candidates = list(cursor.fetchall())
if not next_candidates:
@ -334,7 +336,9 @@ class LessListenedRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
return (
qs.filter(artist__content_category="music")
.exclude(pk__in=listened)
@ -350,7 +354,9 @@ class LessListenedLibraryRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True
)

Wyświetl plik

@ -188,7 +188,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
track_ids = (
kwargs["user"].actor.track_favorites.all().values_list("track", flat=True)
)
return qs.filter(pk__in=track_ids, artist__content_category="music")
@ -325,22 +327,21 @@ class SimilarRadio(RelatedObjectRadio):
def find_next_id(self, queryset, seed):
with connection.cursor() as cursor:
query = """
SELECT next, count(next) AS c
query = """SELECT next, count(next) AS c
FROM (
SELECT
track_id,
creation_date,
h.creation_date,
LEAD(track_id) OVER (
PARTITION by user_id order by creation_date asc
PARTITION by actor_id ORDER BY h.creation_date ASC
) AS next
FROM history_listening
INNER JOIN users_user ON (users_user.id = user_id)
WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' OR user_id = %s
ORDER BY creation_date ASC
) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC;
FROM history_listening h
INNER JOIN federation_actor fa ON (fa.id = h.actor_id)
WHERE fa.privacy_level = 'instance' OR fa.privacy_level = 'everyone' OR h.actor_id = %s
ORDER BY h.creation_date ASC
) t WHERE track_id = %s AND next IS NOT NULL AND next != %s GROUP BY next ORDER BY c DESC;
"""
cursor.execute(query, [self.session.user_id, seed, seed])
cursor.execute(query, [self.session.user.actor, seed, seed])
next_candidates = list(cursor.fetchall())
if not next_candidates:
@ -374,7 +375,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 +393,9 @@ class LessListenedLibraryRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True
)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -36,7 +36,6 @@ def delete_non_alnum_characters(text):
def resolve_recordings_to_fw_track(recordings):
"""
Tries to match a troi recording entity to a fw track using the typesense index.
It will save the results in the match_mbid attribute of the Track table.
For test purposes : if multiple fw tracks are returned, we log the information
but only keep the best result in db to avoid duplicates.
"""

Wyświetl plik

@ -60,14 +60,13 @@ class UserAdmin(AuthUserAdmin):
list_filter = [
"is_superuser",
"is_staff",
"privacy_level",
"permission_settings",
"permission_library",
"permission_moderation",
]
actions = [disable, enable]
fieldsets = (
(None, {"fields": ("username", "password", "privacy_level")}),
(None, {"fields": ("username", "password")}),
(
_("Personal info"),
{"fields": ("first_name", "last_name", "email", "avatar")},

Wyświetl plik

@ -0,0 +1,17 @@
# Generated by Django 4.2.9 on 2024-04-18 17:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("users", "0023_merge_20221125_1902"),
("federation", "0030_actor_privacy_level"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="privacy_level",
),
]

Wyświetl plik

@ -120,7 +120,6 @@ class User(AbstractUser):
# updated on logout or password change, to invalidate JWT
secret_key = models.UUIDField(default=uuid.uuid4, null=True)
privacy_level = fields.get_privacy_field()
# Unfortunately, Subsonic API assumes a MD5/password authentication
# scheme, which is weak in terms of security, and not achievable
@ -214,6 +213,10 @@ class User(AbstractUser):
u.settings[key] = value
u.save(update_fields=["settings"])
self.settings = u.settings
# to do : this is never called
if "privacy_level" in settings:
u.actor.privacy_level = settings["privacy_level"]
u.actor.save()
def has_permissions(self, *perms, **kwargs):
operator = kwargs.pop("operator", "and")

Wyświetl plik

@ -157,7 +157,6 @@ class UserWriteSerializer(serializers.ModelSerializer):
model = models.User
fields = [
"name",
"privacy_level",
"avatar",
"instance_support_message_display_date",
"funkwhale_support_message_display_date",
@ -204,7 +203,6 @@ class UserReadSerializer(serializers.ModelSerializer):
"is_superuser",
"permissions",
"date_joined",
"privacy_level",
"avatar",
]

Wyświetl plik

@ -13,7 +13,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from funkwhale_api.common import preferences, throttling
from funkwhale_api.federation import routes
from . import models, serializers, tasks
@ -94,6 +94,8 @@ 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)
if "privacy_level" in new_settings:
dispatch_privacy_downgrade(new_settings["privacy_level"], request.user)
return Response(request.user.settings)
@action(
@ -137,9 +139,17 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
serializer.save(request)
return Response(status=204)
# to do : this work but maybe front should send privacy level update on the actor endpoint an not hte user endpoint ?
def update(self, request, *args, **kwargs):
if not self.request.user.username == kwargs.get("username"):
return Response(status=403)
if "privacy_level" in request.data:
user = self.get_object()
request.data._mutable = True
privacy_level = request.data.pop("privacy_level")
request.data._mutable = False
user.actor.privacy_level = privacy_level[0]
user.actor.save()
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
@ -179,3 +189,18 @@ 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":
# this will automatically delete all related actor acitivities
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": user.actor.type}},
context={"actor": user.actor},
)
if privacy_level == "followers":
routes.outbox.dispatch(
{"type": "Update", "object": {"type": user.actor.type}},
context={"actor": user.actor},
)

Wyświetl plik

@ -2,18 +2,27 @@ 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"]()
user.create_actor(privacy_level="everyone")
user2 = factories["users.User"]()
user2.create_actor(privacy_level="instance")
listening1 = 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]
assert objects == [favorite1, listening1]
# to do : test others

Wyświetl plik

@ -5,7 +5,9 @@ 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"]()
user.create_actor(privacy_level="everyone")
factories["favorites.TrackFavorite"](actor=user.actor)
factories["history.Listening"]()
url = reverse("api:v1:activity-list")
objects = utils.get_activity(anonymous_user)

Wyświetl plik

@ -1,25 +1,91 @@
import pytest
from django.contrib.auth.models import AnonymousUser
from django.db.models import Q
from django.db.models import Q, QuerySet
from funkwhale_api.common import fields
from funkwhale_api.users.factories import UserFactory
from funkwhale_api.history import models
from funkwhale_api.favorites import models as favorite_models
from funkwhale_api.federation import models as federation_models
def test_privacy_level_query(factories):
user = factories["users.User"](with_actor=True)
user_query = (
Q(privacy_level__in=["instance", "everyone"])
| Q(privacy_level="me", user=user)
| Q(
privacy_level="followers",
user__actor__pk__in=user.actor.user_follows.filter(
approved=True
).values_list("target", flat=True),
)
)
@pytest.mark.parametrize(
"user,expected",
[
(AnonymousUser(), Q(privacy_level="everyone")),
(
UserFactory.build(pk=1),
Q(privacy_level__in=["instance", "everyone"])
| Q(privacy_level="me", user=UserFactory.build(pk=1)),
),
],
)
def test_privacy_level_query(user, expected):
query = fields.privacy_level_query(user)
assert query == expected
assert str(query) == str(user_query)
user = AnonymousUser()
user_query = Q(privacy_level="everyone")
query = fields.privacy_level_query(user)
assert str(query) == str(user_query)
def test_privacy_level_query_followers(factories):
user = factories["users.User"](with_actor=True)
target = factories["users.User"]()
target.create_actor(privacy_level="followers")
target.refresh_from_db()
userfollow = factories["federation.UserFollow"](
actor=user.actor, target=target.actor, approved=True
)
listening = factories["history.Listening"](actor=userfollow.target)
favorite = factories["favorites.TrackFavorite"](actor=userfollow.target)
factories["history.Listening"]()
factories["history.Listening"]()
factories["favorites.TrackFavorite"]()
factories["favorites.TrackFavorite"]()
queryset = models.Listening.objects.all().filter(
fields.privacy_level_query(user, "actor__privacy_level", "actor__user")
)
fav_qs = favorite_models.TrackFavorite.objects.all().filter(
fields.privacy_level_query(user, "actor__privacy_level", "actor__user")
)
assert listening in queryset
assert favorite in fav_qs
def test_privacy_level_query_not_followers(factories):
user = factories["users.User"](with_actor=True)
target = factories["users.User"]()
target.create_actor(privacy_level="followers")
target.refresh_from_db()
userfollow = factories["federation.UserFollow"](target=target.actor, approved=True)
listening = factories["history.Listening"](actor=userfollow.target)
favorite = factories["favorites.TrackFavorite"](actor=userfollow.target)
factories["history.Listening"]()
factories["history.Listening"]()
factories["favorites.TrackFavorite"]()
factories["favorites.TrackFavorite"]()
queryset = models.Listening.objects.all().filter(
fields.privacy_level_query(user, "actor__privacy_level", "actor__user")
)
fav_qs = favorite_models.TrackFavorite.objects.all().filter(
fields.privacy_level_query(user, "actor__privacy_level", "actor__user")
)
assert listening not in queryset
assert favorite not in fav_qs
def test_generic_relation_field(factories):

Wyświetl plik

@ -39,3 +39,58 @@ 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"]()
user.create_actor(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"]()
user.create_actor(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"]()
user.create_actor(privacy_level=privacy_level)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected

Wyświetl plik

@ -0,0 +1,334 @@
import datetime
import logging
import liblistenbrainz
import pytest
from django.urls import reverse
from django.utils import timezone
from config import plugins
from funkwhale_api.contrib.listenbrainz import funkwhale_ready
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
def test_listenbrainz_submit_listen(logged_in_client, mocker, factories):
logged_in_client.user.create_actor()
config = plugins.get_plugin_config(
name="listenbrainz",
description="A plugin that allows you to submit or sync your listens and favorites to ListenBrainz.",
conf=[],
source=False,
)
handler = mocker.Mock()
plugins.register_hook(plugins.LISTENING_CREATED, config)(handler)
plugins.set_conf(
"listenbrainz",
{
"sync_listenings": True,
"sync_favorites": True,
"submit_favorites": True,
"sync_favorites": True,
"user_token": "blablabla",
},
user=logged_in_client.user,
)
plugins.enable_conf("listenbrainz", True, logged_in_client.user)
track = factories["music.Track"]()
url = reverse("api:v1:history:listenings-list")
logged_in_client.post(url, {"track": track.pk})
logged_in_client.get(url)
listening = history_models.Listening.objects.get(actor=logged_in_client.user.actor)
handler.assert_called_once_with(listening=listening, conf=None)
def test_sync_listenings_from_listenbrainz(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"]()
factories["music.Track"](mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476")
track = factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
factories["history.Listening"](
creation_date=datetime.datetime.fromtimestamp(1871, timezone.utc), track=track
)
conf = {
"user_name": user.username,
"user_token": "user_tolkien",
"sync_listenings": True,
}
listens = {
"payload": {
"count": 25,
"user_id": "-- the MusicBrainz ID of the user --",
"listens": [
liblistenbrainz.Listen(
track_name="test",
artist_name="artist_test",
recording_mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476",
additional_info={"submission_client": "not funkwhale"},
listened_at=-3124224000,
),
liblistenbrainz.Listen(
track_name="test2",
artist_name="artist_test2",
recording_mbid="54c60860-f43d-484e-b691-7ab7ec8de559",
additional_info={
"submission_client": "Funkwhale ListenBrainz plugin"
},
listened_at=1871,
),
liblistenbrainz.Listen(
track_name="test3",
artist_name="artist_test3",
listened_at=0,
),
],
}
}
no_more_listen = {
"payload": {
"count": 25,
"user_id": "Bilbo",
"listens": [],
}
}
mocker.patch.object(
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
"get_listens",
side_effect=[listens, no_more_listen],
)
funkwhale_ready.sync_listenings_from_listenbrainz(user, conf)
assert history_models.Listening.objects.filter(
track__mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476"
).exists()
assert "Listen with ts 1871 skipped because already in db" in caplog.text
assert "Received listening that doesn't have a mbid. Skipping..." in caplog.text
def test_sync_favorites_from_listenbrainz(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"](with_actor=True)
# track lb fav
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
# random track
factories["music.Track"]()
# track lb neutral
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track, actor=user.actor)
# last_sync
track_last_sync = factories["music.Track"](
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
)
factories["favorites.TrackFavorite"](track=track_last_sync, source="Listenbrainz")
conf = {
"user_name": user.username,
"user_token": "user_tolkien",
"sync_favorites": True,
}
feedbacks = {
"count": 5,
"feedback": [
{
"created": 1701116226,
"recording_mbid": "195565db-65f9-4d0d-b347-5f0c85509528",
"score": 1,
"user_id": user.username,
},
{
"created": 1701116214,
"recording_mbid": "c5af5351-dbbf-4481-b52e-a480b6c57986",
"score": 0,
"user_id": user.username,
},
{
# last sync
"created": 1690775094,
"recording_mbid": "c878ef2f-c08d-4a81-a047-f2a9f978cec7",
"score": -1,
"user_id": user.username,
},
{
"created": 1690775093,
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec1965d2",
"score": 1,
"user_id": user.username,
},
],
"offset": 0,
"total_count": 4,
}
empty_feedback = {"count": 0, "feedback": [], "offset": 0, "total_count": 0}
mocker.patch.object(
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
"get_user_feedback",
side_effect=[feedbacks, empty_feedback],
)
funkwhale_ready.sync_favorites_from_listenbrainz(user, conf)
assert favorites_models.TrackFavorite.objects.filter(
track__mbid="195565db-65f9-4d0d-b347-5f0c85509528"
).exists()
with pytest.raises(favorites_models.TrackFavorite.DoesNotExist):
favorite.refresh_from_db()
def test_sync_favorites_from_listenbrainz_since(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"](with_actor=True)
# track lb fav
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
# track lb neutral
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track, actor=user.actor)
# track should be not synced
factories["music.Track"](mbid="1fd02cf2-7247-4715-8862-c378ec196000")
# last_sync
track_last_sync = factories["music.Track"](
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
)
factories["favorites.TrackFavorite"](
track=track_last_sync,
actor=user.actor,
source="Listenbrainz",
creation_date=datetime.datetime.fromtimestamp(1690775094),
)
conf = {
"user_name": user.username,
"user_token": "user_tolkien",
"sync_favorites": True,
}
feedbacks = {
"count": 5,
"feedback": [
{
"created": 1701116226,
"recording_mbid": "195565db-65f9-4d0d-b347-5f0c85509528",
"score": 1,
"user_id": user.username,
},
{
"created": 1701116214,
"recording_mbid": "c5af5351-dbbf-4481-b52e-a480b6c57986",
"score": 0,
"user_id": user.username,
},
{
# last sync
"created": 1690775094,
"recording_mbid": "c878ef2f-c08d-4a81-a047-f2a9f978cec7",
"score": -1,
"user_id": user.username,
},
{
"created": 1690775093,
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec1965d2",
"score": 1,
"user_id": user.username,
},
],
"offset": 0,
"total_count": 4,
}
second_feedback = {
"count": 0,
"feedback": [
{
"created": 0,
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec196000",
"score": 1,
"user_id": user.username,
},
],
"offset": 0,
"total_count": 0,
}
mocker.patch.object(
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
"get_user_feedback",
side_effect=[feedbacks, second_feedback],
)
funkwhale_ready.sync_favorites_from_listenbrainz(user, conf)
assert favorites_models.TrackFavorite.objects.filter(
track__mbid="195565db-65f9-4d0d-b347-5f0c85509528"
).exists()
assert not favorites_models.TrackFavorite.objects.filter(
track__mbid="1fd02cf2-7247-4715-8862-c378ec196000"
).exists()
with pytest.raises(favorites_models.TrackFavorite.DoesNotExist):
favorite.refresh_from_db()
def test_submit_favorites_to_listenbrainz(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"]()
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track)
conf = {
"user_name": user.username,
"user_token": "user_tolkien",
"submit_favorites": True,
}
patch = mocker.patch.object(
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
"submit_user_feedback",
return_value="Success",
)
funkwhale_ready.submit_favorite_creation(favorite, conf)
patch.assert_called_once_with(1, track.mbid)
def test_submit_favorites_deletion(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"]()
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track)
conf = {
"user_name": user.username,
"user_token": "user_tolkien",
"submit_favorites": True,
}
patch = mocker.patch.object(
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
"submit_user_feedback",
return_value="Success",
)
funkwhale_ready.submit_favorite_deletion(favorite, conf)
patch.assert_called_once_with(0, track.mbid)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -5,7 +5,9 @@ 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"]()
user.create_actor(privacy_level=level)
factories["favorites.TrackFavorite"](actor=user.actor)
url = reverse("api:v1:favorites:tracks-list")
response = api_client.get(url)
assert response.status_code == 200

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -7,8 +7,11 @@ from funkwhale_api.federation import (
jsonld,
routes,
serializers,
utils,
)
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.favorites import serializers as favorites_serializers
@pytest.mark.parametrize(
@ -36,6 +39,10 @@ from funkwhale_api.moderation import serializers as moderation_serializers
({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor),
({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete),
({"type": "Flag"}, routes.inbox_flag),
(
{"type": "Like", "object": {"type": "Track"}},
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": "Like", "object": {"type": "Track"}},
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"]().create_actor(privacy_level="public")
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,74 @@ 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)
userfollow = factories["federation.UserFollow"](target=favorite.actor)
activity = list(routes.outbox_create_favorite({"favorite": favorite}))[0]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": favorite.fid,
"object": {"type": "Track", "id": favorite.track.fid},
}
)
expected = serializer.data
expected["to"] = [{"type": "followers", "target": favorite.actor}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == favorite.actor
def test_inbox_create_favorite(factories, mocker):
actor = factories["federation.Actor"]()
favorite = factories["favorites.TrackFavorite"](actor=actor)
serializer = serializers.TrackFavoriteSerializer(favorite)
init = mocker.spy(serializers.TrackFavoriteSerializer, "__init__")
save = mocker.spy(serializers.TrackFavoriteSerializer, "save")
mocker.patch.object(utils, "retrieve_ap_object", return_value=favorite.track)
favorite.delete()
result = routes.inbox_create_favorite(
serializer.data,
context={
"actor": favorite.actor,
"raise_exception": True,
},
)
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == serializers.TrackFavoriteSerializer(result["object"]).data
# assert args[1]["context"] == {"activity": activity, "actor": favorite.actor}
assert save.call_count == 1
assert favorites_models.TrackFavorite.objects.filter(
track=favorite.track, actor=favorite.actor
).exists()
def test_routes_user(factories):
favorite = factories["favorites.TrackFavorite"]()
follow = factories["federation.UserFollow"](target=favorite.actor, approved=True)
data = serializers.TrackFavoriteSerializer(favorite).data
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": data,
"actor": favorite.actor.fid,
}
)
activities = routes.inbox.dispatch(
serializer.data,
context={
"activity": serializer.data,
"actor": favorite.actor.fid,
# "inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"),
},
)
assert len(activities) == 1

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -5,7 +5,9 @@ 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"]()
user.create_actor(privacy_level=level)
factories["history.Listening"](actor__user=user)
url = reverse("api:v1:history:listenings-list")
response = api_client.get(url)
assert response.status_code == 200

Wyświetl plik

@ -89,6 +89,7 @@ def test_manage_actor_serializer(factories, now, to_api_date):
"user": None,
"instance_policy": None,
"is_local": False,
"privacy_level": "instance",
}
s = serializers.ManageActorSerializer(actor)

Wyświetl plik

@ -5,6 +5,7 @@ from funkwhale_api.playlists import models
def test_can_create_playlist_via_api(logged_in_api_client):
logged_in_api_client.user.create_actor()
url = reverse("api:v1:playlists-list")
data = {"name": "test", "privacy_level": "everyone"}
@ -16,6 +17,7 @@ def test_can_create_playlist_via_api(logged_in_api_client):
def test_serializer_includes_tracks_count(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
factories["playlists.PlaylistTrack"](playlist=playlist)
@ -26,6 +28,7 @@ def test_serializer_includes_tracks_count(factories, logged_in_api_client):
def test_serializer_includes_tracks_count_986(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
factories["music.Upload"].create_batch(
@ -38,6 +41,7 @@ def test_serializer_includes_tracks_count_986(factories, logged_in_api_client):
def test_serializer_includes_is_playable(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
factories["playlists.PlaylistTrack"](playlist=playlist)
@ -48,16 +52,17 @@ def test_serializer_includes_is_playable(factories, logged_in_api_client):
def test_playlist_inherits_user_privacy(logged_in_api_client):
logged_in_api_client.user.create_actor()
url = reverse("api:v1:playlists-list")
user = logged_in_api_client.user
user.privacy_level = "me"
user.actor.privacy_level = "me"
user.save()
data = {"name": "test"}
logged_in_api_client.post(url, data)
playlist = user.playlists.latest("id")
assert playlist.privacy_level == user.privacy_level
assert playlist.privacy_level == user.actor.privacy_level
@pytest.mark.parametrize(
@ -73,6 +78,7 @@ def test_url_requires_login(name, method, factories, api_client):
def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
track = factories["music.Track"]()
playlist = factories["playlists.Playlist"]()
url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk})
@ -84,6 +90,7 @@ def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_cli
def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
remove = mocker.spy(models.Playlist, "remove")
factories["music.Track"]()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
@ -115,6 +122,7 @@ def test_playlist_privacy_respected_in_list_anon(
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
response = getattr(logged_in_api_client, method.lower())(url)
@ -125,6 +133,7 @@ def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
def test_can_add_multiple_tracks_at_once_via_api(
factories, mocker, logged_in_api_client
):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
tracks = factories["music.Track"].create_batch(size=5)
track_ids = [t.id for t in tracks]
@ -141,6 +150,7 @@ def test_can_add_multiple_tracks_at_once_via_api(
def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, preferences):
logged_in_api_client.user.create_actor()
preferences["playlists__max_tracks"] = 3
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
tracks = factories["music.Track"].create_batch(
@ -155,6 +165,7 @@ def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, prefer
def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk})
@ -165,6 +176,7 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
def test_update_playlist_from_api(factories, mocker, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
@ -176,6 +188,7 @@ def test_update_playlist_from_api(factories, mocker, logged_in_api_client):
def test_move_plt_updates_indexes(mocker, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -339,6 +339,7 @@ def test_stream_transcode(
@pytest.mark.parametrize("f", ["json"])
def test_star(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-star")
assert url.endswith("star") is True
track = factories["music.Track"]()
@ -347,30 +348,34 @@ def test_star(f, db, logged_in_api_client, factories):
assert response.status_code == 200
assert response.data == {"status": "ok"}
favorite = logged_in_api_client.user.track_favorites.latest("id")
favorite = logged_in_api_client.user.actor.track_favorites.latest("id")
assert favorite.track == track
@pytest.mark.parametrize("f", ["json"])
def test_unstar(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-unstar")
assert url.endswith("unstar") is True
track = factories["music.Track"]()
factories["favorites.TrackFavorite"](track=track, user=logged_in_api_client.user)
factories["favorites.TrackFavorite"](
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
assert response.status_code == 200
assert response.data == {"status": "ok"}
assert logged_in_api_client.user.track_favorites.count() == 0
assert logged_in_api_client.user.actor.track_favorites.count() == 0
@pytest.mark.parametrize("f", ["json"])
def test_get_starred2(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_starred2")
assert url.endswith("getStarred2") is True
track = factories["music.Track"]()
favorite = factories["favorites.TrackFavorite"](
track=track, user=logged_in_api_client.user
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
@ -427,11 +432,12 @@ def test_get_genres(f, db, logged_in_api_client, factories, mocker):
@pytest.mark.parametrize("f", ["json"])
def test_get_starred(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_starred")
assert url.endswith("getStarred") is True
track = factories["music.Track"]()
favorite = factories["favorites.TrackFavorite"](
track=track, user=logged_in_api_client.user
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
@ -627,6 +633,7 @@ def test_search3(f, db, logged_in_api_client, factories):
@pytest.mark.parametrize("f", ["json"])
def test_get_playlists(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_playlists")
assert url.endswith("getPlaylists") is True
playlist1 = factories["playlists.PlaylistTrack"](
@ -658,6 +665,7 @@ def test_get_playlists(f, db, logged_in_api_client, factories):
@pytest.mark.parametrize("f", ["json"])
def test_get_playlist(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_playlist")
assert url.endswith("getPlaylist") is True
playlist = factories["playlists.PlaylistTrack"](
@ -832,6 +840,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 +849,7 @@ def test_scrobble(factories, logged_in_api_client):
assert response.status_code == 200
listening = logged_in_api_client.user.listenings.latest("id")
listening = logged_in_api_client.user.actor.listenings.latest("id")
assert listening.track == track

Wyświetl plik

@ -190,16 +190,16 @@ def test_can_request_password_reset(
def test_user_can_patch_his_own_settings(logged_in_api_client):
logged_in_api_client.user.create_actor()
user = logged_in_api_client.user
payload = {"privacy_level": "me"}
url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
response = logged_in_api_client.patch(url, payload)
assert response.status_code == 200
user.refresh_from_db()
assert user.privacy_level == "me"
assert user.actor.privacy_level == "me"
def test_user_can_patch_description(logged_in_api_client):
@ -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.actor.privacy_level == "me"

Wyświetl plik

@ -0,0 +1 @@
Add favorite and listening sync ith Listenbrainz (#2079)

Wyświetl plik

@ -0,0 +1 @@
Add genre tags spec.

Wyświetl plik

@ -25,7 +25,7 @@ services:
env_file:
- .env.dev
- .env
image: postgres:${POSTGRES_VERSION-11}-alpine
image: postgres:${POSTGRES_VERSION-15}-alpine
environment:
- "POSTGRES_HOST_AUTH_METHOD=trust"
command: postgres ${POSTGRES_ARGS-}
@ -139,6 +139,8 @@ services:
- "./front:/frontend:ro"
- "./data/staticfiles:/staticfiles:ro"
- "./data/media:/protected/media:ro"
- "./data/media:/data/media:ro"
networks:
- federation
- internal

Wyświetl plik

@ -109,6 +109,7 @@ specs/multi-artist/index
specs/user-follow/index
specs/user-deletion/index
specs/upload-process/index
specs/genre-tags/index
```

Wyświetl plik

@ -0,0 +1,239 @@
# Genre tags
## The issue
Funkwhale offers users a facility to assign genre tags to items such as tracks, albums, and artists. The `tags_tag` table is populated automatically when new tags are found in uploaded content, and users can also enter custom tags. By default, the table is empty. This means that a user on a new pod won't see any results when attempting to tag items in the frontend.
## The solution
To provide the best experience for new Funkwhale users, we should pre-populate this table with [genre tags from Musicbrainz](https://musicbrainz.org/genres). Doing this enables users to easily search for and select the tags they want to assign to their content without needing to create custom tags or upload tagged content.
Having these tags easily available also facilitates better tagging within Funkwhale in future, reducing the reliance on external tools such as Picard.
## Feature behavior
### Backend behavior
The `tags_tag` table contains the following fields:
| Field | Data type | Description | Relations | Constraints |
| ---------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------- |
| `id` | Integer | The randomly generated table ID | `tags_taggeditem.tag_id` foreign key | None |
| `musicbrainz_id` | UUID | The Musicbrainz genre tag `id`. Used to identify the tag in Musicbrainz fetches | None | None |
| `name` | String | The name of the tag. Assigned by Funkwhale during creation for use in URLs. Uses Pascal casing for consistency | None | Must be unique |
| `display_name` | String | The name of the tag as the user entered it or as it was originally written by Musicbrainz. Lowercase, normalizes spaces | None | None |
| `creation_date` | Timestamp with time zone | The date on which the tag was created | None | None |
#### Musicbrainz fetch task
To keep Funkwhale's database up-to-date with Musicbrainz's genre tags, we must fetch information from Musicbrainz periodically. We can use the following endpoint to fetch the information:
```text
https://musicbrainz.org/ws/2/genre/all
```
This endpoint accepts the `application/json` header for a JSON response. See the [Musicbrainz API documentation](https://musicbrainz.org/doc/MusicBrainz_API) for more information. The pagination can be controlled by passing the following options:
- `limit`: the number of results to return
- `offset`: the starting point of the page
The fetch task should fetch **all** pages, using the response `genre-count` to determine how many offset positions to pass.
```json
{
"genre-count": 1913,
"genre-offset": 24,
"genres": [
{
"disambiguation": "",
"id": "243975aa-1250-4429-8bd3-97080af44cf7",
"name": "afro trap"
},
{
"name": "afro-cuban jazz",
"id": "cdb11433-1ff1-4c88-be16-717567e1342f",
"disambiguation": ""
},
{
"name": "afro-funk",
"disambiguation": "",
"id": "fc00175b-2be9-4d73-ba91-27b3ca827223"
},
{
"name": "afro-jazz",
"disambiguation": "",
"id": "6f33d775-b4e2-473c-a7db-e34c525cc52d"
},
{
"disambiguation": "",
"id": "a7e0229c-6e53-45f1-a6f2-a791e78b159e",
"name": "afro-zouk"
},
{
"disambiguation": "funk/soul + West African sounds",
"id": "fcc58a18-9326-4c92-8b29-c294d44379c3",
"name": "afrobeat"
},
{
"id": "b8793fdb-bbc8-4418-a6f8-05eafbbe07ef",
"disambiguation": "West African urban/pop music",
"name": "afrobeats"
},
{
"name": "afropiano",
"id": "d42b567f-0952-424b-959d-bee6e5961cc0",
"disambiguation": ""
},
{
"disambiguation": "",
"id": "52349b68-9cad-496e-8785-00d53f410246",
"name": "afroswing"
},
{
"name": "agbadza",
"disambiguation": "",
"id": "c6d1e78b-ac82-4bb8-89d5-21e3226dc906"
},
{
"disambiguation": "",
"id": "b8ae0a3c-5826-4104-9663-fe8f828effa9",
"name": "agbekor"
},
{
"name": "aggrotech",
"disambiguation": "",
"id": "c844c144-90a8-4288-981e-e38275592688"
},
{
"name": "ahwash",
"id": "4802e6e4-f403-41d1-8e58-76e5cf4df81d",
"disambiguation": ""
},
{
"id": "50cc5641-b4f9-40b7-bf7a-6d903ac6c1c5",
"disambiguation": "",
"name": "aita"
},
{
"id": "aebbce35-0e8b-40e9-b04c-bebbbda124d0",
"disambiguation": "",
"name": "akishibu-kei"
},
{
"name": "al jeel",
"disambiguation": "",
"id": "0f8d3ff4-8cda-42c4-b462-10352cd01606"
},
{
"name": "algerian chaabi",
"id": "998efb76-2f98-41c8-8c5f-74c32e405e9f",
"disambiguation": ""
},
{
"name": "algorave",
"id": "e0a9d0d1-b86f-4344-82a9-022a84627087",
"disambiguation": ""
},
{
"name": "alloukou",
"disambiguation": "",
"id": "e367c884-d94d-4fba-abc4-8ac51d167ccf"
},
{
"disambiguation": "",
"id": "ef1d11cc-e70f-4885-ad6c-103f060d33b2",
"name": "alpenrock"
},
{
"disambiguation": "",
"id": "5f9cba3d-1a9f-46cd-8c49-7ed78d1f3354",
"name": "alternative country"
},
{
"name": "alternative dance",
"id": "8301f73c-9166-4108-bfeb-4fd22dc19083",
"disambiguation": ""
},
{
"name": "alternative folk",
"id": "0b48a36c-630f-4ee7-8cf3-480e3dd8be65",
"disambiguation": ""
},
{
"name": "alternative hip hop",
"disambiguation": "",
"id": "924943cd-73c8-45c0-96eb-74f2a15e5d6e"
},
{
"disambiguation": "",
"id": "7c4d0994-4c49-4c74-8763-df27fc0084cc",
"name": "alté"
}
]
}
```
The fetch task should run _initially upon first startup_ and then _monthly_ thereafter. The pod admin must be able to disable this job or run it manually at their discretion.
The task should use the following logic:
1. Call the Musicbrainz API to fetch new data
2. Verify the listed entries against the Funkwhale tag table. The `id` field in the response should be checked against the `musicbrainz_id` field
3. Any entries that do not currently exist in Funkwhale should be added with the following mapping:
| Musicbrainz response field | Tags table column | Notes |
| -------------------------- | ----------------- | --------------------------------------------------------------------------------- |
| `id` | `musicbrainz_id` | |
| `name` | `display_name` | Funkwhale should automatically generate a Pascal cased `name` based on this entry |
4. If the `display_name` of a tag **exactly matches** a `name` in the Musicbrainz response but the tag has no `musicbrainz_id`, the `musicbrainz_id` should be populated
### Frontend behavior
#### Tagged uploads
When a user uploads new content with genre tags, the tagged item should be linked to any existing tags and new ones should be created if they're not found.
#### In-app tagging
When a user uploads new content with _no_ genre tags, they should be able to select tags from a dropdown menu. This menu is populated with the tags from the database with the `display_name` shown in the list. When a tag is selected, the item is linked to the associated tag.
If a user inserts a new tag, Funkwhale should:
1. Store the entered string as the tag's `display_name`
2. Generate a Pascal cased `name` for the tag
3. Associate the targeted object with the new tag
#### Search results
Users should be able to search for tags using Funkwhale's in-app search. In search autocomplete and search results page, the `display_name` should be used. The `name` of the tag should be used to populate the search URL.
#### Cards
The `display_name` of the tag should be shown in pills against cards.
### Admin options
If the admin of a server wants to **disable** MusicBrainz tagging, they should be able to toggle this in their instance settings. If the setting is **disabled**:
- The sync task should stop running
- Any tags with an `musicbrainz_id` should be excluded from API queries.
## Availability
- [x] Admin panel
- [x] App frontend
- [x] CLI
## Responsible parties
- Backend group:
- Update the tracks table to support the new information
- Update the API to support the new information, or create a new v2 endpoint
- Create the new fetch task
- Add admin controls for the new task
- Frontend group:
- Update views to use `display_name` instead of `name` for tag results
- Update API calls to use the new API structure created by the backend group
- Documentation group:
- Document the new task and settings for admins

Wyświetl plik

@ -19,8 +19,8 @@ The **ListenBrainz** plugin enables you to submit ({term}`scrobble<Scrobbling>`)
:::
:::{tab-item} Desktop
:sync: desktop
:::{tab-item} Mobile
:sync: mobile
1. Log in to your account.
2. Select the cog icon ({fa}`cog`) or your avatar to open the {guilabel}`Options` menu.
@ -36,3 +36,48 @@ The **ListenBrainz** plugin enables you to submit ({term}`scrobble<Scrobbling>`)
::::
That's it! You've set up the **ListenBrainz** plugin. When you listen to tracks, the plugin sends the information to ListenBrainz.
## Enable data synchronization
The ListenBrainz plugin supports synchronizing listenings and track favorites with Funkwhale. To enable support for synchronization:
::::{tab-set}
:::{tab-item} Desktop
:sync: desktop
1. Log in to your account.
2. Select the cog icon ({fa}`cog`) or your avatar to expand the user menu.
3. Select {guilabel}`Settings`.
4. Scroll down to the {guilabel}`Plugins` section.
5. Select {guilabel}`Manage plugins`.
6. Find the {guilabel}`ListenBrainz` plugin.
7. Enter {guilabel}`Your ListenBrainz user name`. You can find this on your ListenBrainz profile.
8. Select the data you want to synchronize. The following options are available:
- {guilabel}`Enable listenings submission to ListenBrainz`: submit your Funkwhale listens to ListenBrainz.
- {guilabel}`Enable listenings sync`: pull listening data from ListenBrainz into Funkwhale.
- {guilabel}`Enable favorite submission to ListenBrainz services`: submit your Funkwhale favorites activity to ListenBrainz.
- {guilabel}`Enable favorite sync`: pull favorites data from ListenBrainz into Funkwhale.
9. Select {guilabel}`Save`.
:::
:::{tab-item} Mobile
:sync: mobile
1. Log in to your account.
2. Select the cog icon ({fa}`cog`) or your avatar to open the {guilabel}`Options` menu.
3. Select {guilabel}`Settings`.
4. Scroll down to the {guilabel}`Plugins` section.
5. Select {guilabel}`Manage plugins`.
6. Find the {guilabel}`ListenBrainz` plugin.
7. Enter {guilabel}`Your ListenBrainz user name`. You can find this on your ListenBrainz profile.
8. Select the data you want to synchronize. The following options are available:
- {guilabel}`Enable listenings submission to ListenBrainz`: submit your Funkwhale listens to ListenBrainz.
- {guilabel}`Enable listenings sync`: pull listening data from ListenBrainz into Funkwhale.
- {guilabel}`Enable favorite submission to ListenBrainz services`: submit your Funkwhale favorites activity to ListenBrainz.
- {guilabel}`Enable favorite sync`: pull favorites data from ListenBrainz into Funkwhale.
9. Select {guilabel}`Save`.
:::
::::

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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