From f273faf9de56e0d265d2093eef847451c25f3d64 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Fri, 6 Apr 2018 18:49:29 +0200 Subject: [PATCH] Added Library model to have more granular federation management --- api/funkwhale_api/federation/actors.py | 12 +++-- api/funkwhale_api/federation/factories.py | 11 +++++ ...406_1319.py => 0003_auto_20180406_1621.py} | 17 ++++++- api/funkwhale_api/federation/models.py | 18 ++++++++ api/funkwhale_api/music/factories.py | 6 ++- ...406_1319.py => 0023_auto_20180406_1621.py} | 31 +++++++------ api/funkwhale_api/music/models.py | 22 +++++---- api/funkwhale_api/music/serializers.py | 6 +-- api/funkwhale_api/music/tasks.py | 12 +++-- api/tests/federation/test_actors.py | 46 +++++++++++++++---- api/tests/federation/test_models.py | 7 +++ api/tests/federation/test_views.py | 2 + api/tests/music/test_import.py | 12 +++-- api/tests/music/test_serializers.py | 10 ++-- 14 files changed, 159 insertions(+), 53 deletions(-) rename api/funkwhale_api/federation/migrations/{0003_auto_20180406_1319.py => 0003_auto_20180406_1621.py} (69%) rename api/funkwhale_api/music/migrations/{0023_auto_20180406_1319.py => 0023_auto_20180406_1621.py} (86%) diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index 6f782ced..205c3486 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -52,7 +52,7 @@ class SystemActor(object): def serialize(self): actor = self.get_actor_instance() - serializer = serializers.ActorSerializer() + serializer = serializers.ActorSerializer(actor) return serializer.data def get_actor_instance(self): @@ -196,8 +196,12 @@ class LibraryActor(SystemActor): from funkwhale_api.music.serializers import ( AudioCollectionImportSerializer) - library = self.get_actor_instance() - if not library.following.filter(url=sender.url).exists(): + try: + remote_library = models.Library.objects.get( + actor=sender, + federation_enabled=True, + ) + except models.Library.DoesNotExist: logger.info( 'Skipping import, we\'re not following %s', sender.url) return @@ -212,7 +216,7 @@ class LibraryActor(SystemActor): serializer = AudioCollectionImportSerializer( data=ac['object'], - context={'sender': sender}) + context={'library': remote_library}) if not serializer.is_valid(): logger.error( diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 63d40aff..fc893239 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -122,6 +122,17 @@ class FollowRequestFactory(factory.DjangoModelFactory): model = models.FollowRequest +@registry.register +class LibraryFactory(factory.DjangoModelFactory): + actor = factory.SubFactory(ActorFactory) + url = factory.Faker('url') + federation_enabled = True + download_files = False + + class Meta: + model = models.Library + + @registry.register(name='federation.Note') class NoteFactory(factory.Factory): type = 'Note' diff --git a/api/funkwhale_api/federation/migrations/0003_auto_20180406_1319.py b/api/funkwhale_api/federation/migrations/0003_auto_20180406_1621.py similarity index 69% rename from api/funkwhale_api/federation/migrations/0003_auto_20180406_1319.py rename to api/funkwhale_api/federation/migrations/0003_auto_20180406_1621.py index cc653b1a..f8771752 100644 --- a/api/funkwhale_api/federation/migrations/0003_auto_20180406_1319.py +++ b/api/funkwhale_api/federation/migrations/0003_auto_20180406_1621.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.3 on 2018-04-06 13:19 +# Generated by Django 2.0.3 on 2018-04-06 16:21 from django.db import migrations, models import django.db.models.deletion @@ -36,6 +36,21 @@ class Migration(migrations.Migration): ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follow_requests', to='federation.Actor')), ], ), + migrations.CreateModel( + name='Library', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('modification_date', models.DateTimeField(auto_now=True)), + ('fetched_date', models.DateTimeField(blank=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4)), + ('url', models.URLField()), + ('federation_enabled', models.BooleanField()), + ('download_files', models.BooleanField()), + ('files_count', models.PositiveIntegerField(blank=True, null=True)), + ('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library', to='federation.Actor')), + ], + ), migrations.AddField( model_name='actor', name='followers', diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 4bf59700..8a90bdb7 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -157,3 +157,21 @@ class FollowRequest(models.Model): def refuse(self): self.approved = False self.save(update_fields=['approved']) + + +class Library(models.Model): + creation_date = models.DateTimeField(default=timezone.now) + modification_date = models.DateTimeField( + auto_now=True) + fetched_date = models.DateTimeField(null=True, blank=True) + actor = models.OneToOneField( + Actor, + on_delete=models.CASCADE, + related_name='library') + uuid = models.UUIDField(default=uuid.uuid4) + url = models.URLField() + # use this flag to disable federation with a library + federation_enabled = models.BooleanField() + # should we mirror files locally or hotlink them? + download_files = models.BooleanField() + files_count = models.PositiveIntegerField(null=True, blank=True) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 27387ca9..8da56b2a 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -5,6 +5,7 @@ from funkwhale_api.factories import registry, ManyToManyFromList from funkwhale_api.federation.factories import ( AudioMetadataFactory, ActorFactory, + LibraryFactory, ) from funkwhale_api.users.factories import UserFactory @@ -68,7 +69,8 @@ class ImportBatchFactory(factory.django.DjangoModelFactory): class Params: federation = factory.Trait( submitted_by=None, - federation_actor=factory.SubFactory(ActorFactory), + source_library=factory.SubFactory(LibraryFactory), + source_library_url=factory.Faker('url'), source='federation', ) @@ -85,7 +87,7 @@ class ImportJobFactory(factory.django.DjangoModelFactory): class Params: federation = factory.Trait( batch=factory.SubFactory(ImportBatchFactory, federation=True), - federation_source=factory.Faker('url'), + source_library_url=factory.Faker('url'), metadata=factory.SubFactory(AudioMetadataFactory), ) diff --git a/api/funkwhale_api/music/migrations/0023_auto_20180406_1319.py b/api/funkwhale_api/music/migrations/0023_auto_20180406_1621.py similarity index 86% rename from api/funkwhale_api/music/migrations/0023_auto_20180406_1319.py rename to api/funkwhale_api/music/migrations/0023_auto_20180406_1621.py index c51a7b9f..2bc8085a 100644 --- a/api/funkwhale_api/music/migrations/0023_auto_20180406_1319.py +++ b/api/funkwhale_api/music/migrations/0023_auto_20180406_1621.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.3 on 2018-04-06 13:19 +# Generated by Django 2.0.3 on 2018-04-06 16:21 from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -11,7 +11,7 @@ import uuid class Migration(migrations.Migration): dependencies = [ - ('federation', '0003_auto_20180406_1319'), + ('federation', '0003_auto_20180406_1621'), ('music', '0022_importbatch_import_request'), ] @@ -28,12 +28,12 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='importbatch', - name='federation_actor', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_batches', to='federation.Actor'), + name='source_library', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_batches', to='federation.Library'), ), migrations.AddField( model_name='importbatch', - name='federation_source', + name='source_library_url', field=models.URLField(blank=True, null=True), ), migrations.AddField( @@ -43,13 +43,13 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='importjob', - name='federation_source', - field=models.URLField(blank=True, null=True), + name='metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(default={}), ), migrations.AddField( model_name='importjob', - name='metadata', - field=django.contrib.postgres.fields.jsonb.JSONField(default={}), + name='source_library_url', + field=models.URLField(blank=True, null=True), ), migrations.AddField( model_name='importjob', @@ -73,13 +73,18 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='trackfile', - name='federation_source', - field=models.URLField(blank=True, null=True), + name='modification_date', + field=models.DateTimeField(auto_now=True), ), migrations.AddField( model_name='trackfile', - name='modification_date', - field=models.DateTimeField(auto_now=True), + name='source_library', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='track_files', to='federation.Library'), + ), + migrations.AddField( + model_name='trackfile', + name='source_library_url', + field=models.URLField(blank=True, null=True), ), migrations.AddField( model_name='trackfile', diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index efffb12d..4c0b6a09 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -417,8 +417,14 @@ class TrackFile(models.Model): creation_date = models.DateTimeField(default=timezone.now) modification_date = models.DateTimeField(auto_now=True) + source_library = models.ForeignKey( + 'federation.Library', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='track_files') # points to the URL of the original trackfile ActivityPub Object - federation_source = models.URLField(null=True, blank=True) + source_library_url = models.URLField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True) acoustid_track_id = models.UUIDField(null=True, blank=True) @@ -470,6 +476,7 @@ IMPORT_STATUS_CHOICES = ( ('skipped', 'Skipped'), ) + class ImportBatch(models.Model): uuid = models.UUIDField( unique=True, db_index=True, default=uuid.uuid4) @@ -496,14 +503,13 @@ class ImportBatch(models.Model): blank=True, on_delete=models.CASCADE) - federation_source = models.URLField(null=True, blank=True) - federation_actor = models.ForeignKey( - 'federation.Actor', - on_delete=models.SET_NULL, + source_library = models.ForeignKey( + 'federation.Library', null=True, blank=True, - related_name='import_batches', - ) + on_delete=models.SET_NULL, + related_name='import_batches') + source_library_url = models.URLField(null=True, blank=True) class Meta: ordering = ['-creation_date'] @@ -534,7 +540,7 @@ class ImportJob(models.Model): choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30) audio_file = models.FileField( upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True) - federation_source = models.URLField(null=True, blank=True) + source_library_url = models.URLField(null=True, blank=True) metadata = JSONField(default={}) class Meta: diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 5cd2f2cc..78b2f7c0 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -210,7 +210,7 @@ class AudioSerializer(serializers.Serializer): return models.ImportJob.objects.create( batch=batch, source=validated_data['url']['href'], - federation_source=validated_data['id'], + source_library_url=validated_data['id'], metadata=metadata, ) @@ -248,8 +248,8 @@ class AudioCollectionImportSerializer(serializers.Serializer): @transaction.atomic def create(self, validated_data): batch = models.ImportBatch.objects.create( - federation_actor=self.context['sender'], - federation_source=validated_data['id'], + source_library=self.context['library'], + source_library_url=validated_data['id'], source='federation', ) for i in validated_data['items']: diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 4f85613e..67b538c1 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -102,14 +102,18 @@ def _do_import(import_job, replace, use_acoustid=True): track_file = track_file or models.TrackFile( track=track, source=import_job.source) track_file.acoustid_track_id = acoustid_track_id - track_file.federation_source = import_job.federation_source + track_file.source_library = import_job.batch.source_library + track_file.source_library_url = import_job.source_library_url if from_file: track_file.audio_file = ContentFile(import_job.audio_file.read()) track_file.audio_file.name = import_job.audio_file.name track_file.duration = duration - elif import_job.federation_source: - # no downloading, we hotlink - pass + elif import_job.source_library_url: + if track_file.source_library.download_files: + raise NotImplementedError() + else: + # no downloading, we hotlink + pass else: track_file.download_file() track_file.save() diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index 7a5e0d31..6f5e8866 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -371,9 +371,9 @@ def test_library_actor_handles_follow_auto_approval( ) -def test_library_actor_handle_create_audio_not_following(mocker, factories): +def test_library_actor_handle_create_audio_no_library(mocker, factories): # when we receive inbox create audio, we should not do anything - # if we're not actually following the sender + # if we don't have a configured library matching the sender mocked_create = mocker.patch( 'funkwhale_api.music.serializers.AudioCollectionImportSerializer.create' ) @@ -396,12 +396,40 @@ def test_library_actor_handle_create_audio_not_following(mocker, factories): music_models.ImportBatch.objects.count() == 0 +def test_library_actor_handle_create_audio_no_library_enabled( + mocker, factories): + # when we receive inbox create audio, we should not do anything + # if we don't have an enabled library + mocked_create = mocker.patch( + 'funkwhale_api.music.serializers.AudioCollectionImportSerializer.create' + ) + disabled_library = factories['federation.Library']( + federation_enabled=False) + actor = disabled_library.actor + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + data = { + 'actor': actor.url, + 'type': 'Create', + 'id': 'http://test.federation/audio/create', + 'object': { + 'id': 'https://batch.import', + 'type': 'Collection', + 'totalItems': 2, + 'items': factories['federation.Audio'].create_batch(size=2) + }, + } + library_actor.system_conf.post_inbox(data, actor=actor) + + mocked_create.assert_not_called() + music_models.ImportBatch.objects.count() == 0 + + def test_library_actor_handle_create_audio(mocker, factories): library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow'](actor=library_actor) + remote_library = factories['federation.Library']() data = { - 'actor': follow.target.url, + 'actor': remote_library.actor.url, 'type': 'Create', 'id': 'http://test.federation/audio/create', 'object': { @@ -412,16 +440,16 @@ def test_library_actor_handle_create_audio(mocker, factories): }, } - library_actor.system_conf.post_inbox(data, actor=follow.target) + library_actor.system_conf.post_inbox(data, actor=remote_library.actor) - batch = follow.target.import_batches.latest('id') + batch = remote_library.import_batches.latest('id') - assert batch.federation_source == data['object']['id'] - assert batch.federation_actor == follow.target + assert batch.source_library_url == data['object']['id'] + assert batch.source_library == remote_library assert batch.jobs.count() == 2 jobs = list(batch.jobs.order_by('id')) for i, a in enumerate(data['object']['items']): job = jobs[i] - assert job.federation_source == a['id'] + assert job.source_library_url == a['id'] assert job.source == a['url']['href'] diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 86e4f4a8..b17b6eb6 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -26,6 +26,7 @@ def test_cannot_duplicate_follow(factories): actor=follow.actor, ) + def test_follow_federation_url(factories): follow = factories['federation.Follow'](local=True) expected = '{}#follows/{}'.format( @@ -76,3 +77,9 @@ def test_follow_request_refused(mocker, factories): assert fr.approved is False assert fr.target.followers.count() == 0 + + +def test_library_model_unique_per_actor(factories): + library = factories['federation.Library']() + with pytest.raises(db.IntegrityError): + factories['federation.Library'](actor=library.actor) diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 6f05a16f..c7e1fc01 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -19,6 +19,8 @@ def test_instance_actors(system_actor, db, settings, api_client): response = api_client.get(url) serializer = serializers.ActorSerializer(actor) + if system_actor == 'library': + response.data.pop('url') assert response.status_code == 200 assert response.data == serializer.data diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index 98174891..7e3ebcc1 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -62,7 +62,8 @@ def test_import_job_from_federation_no_musicbrainz(factories): tf = job.track_file assert tf.source == job.source - assert tf.federation_source == job.federation_source + assert tf.source_library == job.batch.source_library + assert tf.source_library_url == job.source_library_url assert tf.track.title == 'Ping' assert tf.track.artist.name == 'Hello' assert tf.track.album.title == 'World' @@ -85,7 +86,8 @@ def test_import_job_from_federation_musicbrainz_recording(factories, mocker): tf = job.track_file assert tf.source == job.source - assert tf.federation_source == job.federation_source + assert tf.source_library == job.batch.source_library + assert tf.source_library_url == job.source_library_url assert tf.track == t track_from_api.assert_called_once_with( mbid=tasks.get_mbid(job.metadata['recording'], 'recording')) @@ -107,7 +109,8 @@ def test_import_job_from_federation_musicbrainz_release(factories, mocker): job.refresh_from_db() tf = job.track_file - assert tf.federation_source == job.federation_source + assert tf.source_library == job.batch.source_library + assert tf.source_library_url == job.source_library_url assert tf.source == job.source assert tf.track.title == 'Ping' assert tf.track.artist == a.artist @@ -134,7 +137,8 @@ def test_import_job_from_federation_musicbrainz_artist(factories, mocker): tf = job.track_file assert tf.source == job.source - assert tf.federation_source == job.federation_source + assert tf.source_library == job.batch.source_library + assert tf.source_library_url == job.source_library_url assert tf.track.title == 'Ping' assert tf.track.artist == a assert tf.track.album.artist == a diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 1270ae76..a1f5999e 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -5,7 +5,7 @@ from funkwhale_api.music import serializers def test_activity_pub_audio_collection_serializer_to_import(factories): - sender = factories['federation.Actor']() + remote_library = factories['federation.Library']() collection = { 'id': 'https://batch.import', @@ -15,7 +15,7 @@ def test_activity_pub_audio_collection_serializer_to_import(factories): } serializer = serializers.AudioCollectionImportSerializer( - data=collection, context={'sender': sender}) + data=collection, context={'library': remote_library}) assert serializer.is_valid(raise_exception=True) @@ -23,13 +23,13 @@ def test_activity_pub_audio_collection_serializer_to_import(factories): jobs = list(batch.jobs.all()) assert batch.source == 'federation' - assert batch.federation_source == collection['id'] - assert batch.federation_actor == sender + assert batch.source_library_url == collection['id'] + assert batch.source_library == remote_library assert len(jobs) == 2 for i, a in enumerate(collection['items']): job = jobs[i] - assert job.federation_source == a['id'] + assert job.source_library_url == a['id'] assert job.source == a['url']['href'] a['metadata']['mediaType'] = a['url']['mediaType'] assert job.metadata == a['metadata'] -- GitLab