Logic to refetch remote entities
This commit is contained in:
parent
63b1007596
commit
cdc617be27
|
@ -30,6 +30,14 @@ class DomainAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Fetch)
|
||||||
|
class FetchAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["url", "actor", "status", "creation_date", "fetch_date", "detail"]
|
||||||
|
search_fields = ["url", "actor__username"]
|
||||||
|
list_filter = ["status"]
|
||||||
|
list_select_related = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Activity)
|
@admin.register(models.Activity)
|
||||||
class ActivityAdmin(admin.ModelAdmin):
|
class ActivityAdmin(admin.ModelAdmin):
|
||||||
list_display = ["type", "fid", "url", "actor", "creation_date"]
|
list_display = ["type", "fid", "url", "actor", "creation_date"]
|
||||||
|
|
|
@ -144,3 +144,19 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer):
|
||||||
|
|
||||||
def handle_read(self, objects):
|
def handle_read(self, objects):
|
||||||
return objects.update(is_read=True)
|
return objects.update(is_read=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchSerializer(serializers.ModelSerializer):
|
||||||
|
actor = federation_serializers.APIActorSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Fetch
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"url",
|
||||||
|
"actor",
|
||||||
|
"status",
|
||||||
|
"detail",
|
||||||
|
"creation_date",
|
||||||
|
"fetch_date",
|
||||||
|
]
|
||||||
|
|
|
@ -3,6 +3,7 @@ from rest_framework import routers
|
||||||
from . import api_views
|
from . import api_views
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
|
router.register(r"fetches", api_views.FetchViewSet, "fetches")
|
||||||
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
|
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
|
||||||
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
|
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
|
||||||
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
|
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
|
||||||
|
|
|
@ -5,6 +5,7 @@ from django.db.models import Count
|
||||||
|
|
||||||
from rest_framework import decorators
|
from rest_framework import decorators
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
|
from rest_framework import permissions
|
||||||
from rest_framework import response
|
from rest_framework import response
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
@ -189,3 +190,10 @@ class InboxItemViewSet(
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = serializer.save()
|
result = serializer.save()
|
||||||
return response.Response(result, status=200)
|
return response.Response(result, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||||
|
|
||||||
|
queryset = models.Fetch.objects.select_related("actor")
|
||||||
|
serializer_class = api_serializers.FetchSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from rest_framework import decorators
|
||||||
|
from rest_framework import permissions
|
||||||
|
from rest_framework import response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from funkwhale_api.common import utils as common_utils
|
||||||
|
|
||||||
|
from . import api_serializers
|
||||||
|
from . import filters
|
||||||
|
from . import models
|
||||||
|
from . import tasks
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
def fetches_route():
|
||||||
|
@transaction.atomic
|
||||||
|
def fetches(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
if request.method == "GET":
|
||||||
|
queryset = models.Fetch.objects.get_for_object(obj).select_related("actor")
|
||||||
|
queryset = queryset.order_by("-creation_date")
|
||||||
|
filterset = filters.FetchFilter(request.GET, queryset=queryset)
|
||||||
|
page = self.paginate_queryset(filterset.qs)
|
||||||
|
if page is not None:
|
||||||
|
serializer = api_serializers.FetchSerializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = api_serializers.FetchSerializer(queryset, many=True)
|
||||||
|
return response.Response(serializer.data)
|
||||||
|
if request.method == "POST":
|
||||||
|
if utils.is_local(obj.fid):
|
||||||
|
return response.Response(
|
||||||
|
{"detail": "Cannot fetch a local object"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
fetch = models.Fetch.objects.create(
|
||||||
|
url=obj.fid, actor=request.user.actor, object=obj
|
||||||
|
)
|
||||||
|
common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk)
|
||||||
|
serializer = api_serializers.FetchSerializer(fetch)
|
||||||
|
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
return decorators.action(
|
||||||
|
methods=["get", "post"],
|
||||||
|
detail=True,
|
||||||
|
permission_classes=[permissions.IsAuthenticated],
|
||||||
|
)(fetches)
|
|
@ -166,7 +166,7 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class LibraryScan(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
class LibraryScanFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
library = factory.SubFactory(MusicLibraryFactory)
|
library = factory.SubFactory(MusicLibraryFactory)
|
||||||
actor = factory.SubFactory(ActorFactory)
|
actor = factory.SubFactory(ActorFactory)
|
||||||
total_files = factory.LazyAttribute(lambda o: o.library.uploads_count)
|
total_files = factory.LazyAttribute(lambda o: o.library.uploads_count)
|
||||||
|
@ -175,6 +175,14 @@ class LibraryScan(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
model = "music.LibraryScan"
|
model = "music.LibraryScan"
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
class FetchFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
|
actor = factory.SubFactory(ActorFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = "federation.Fetch"
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class ActivityFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
class ActivityFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
actor = factory.SubFactory(ActorFactory)
|
actor = factory.SubFactory(ActorFactory)
|
||||||
|
|
|
@ -46,3 +46,14 @@ class InboxItemFilter(django_filters.FilterSet):
|
||||||
|
|
||||||
def filter_before(self, queryset, field_name, value):
|
def filter_before(self, queryset, field_name, value):
|
||||||
return queryset.filter(pk__lte=value)
|
return queryset.filter(pk__lte=value)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchFilter(django_filters.FilterSet):
|
||||||
|
ordering = django_filters.OrderingFilter(
|
||||||
|
# tuple-mapping retains order
|
||||||
|
fields=(("creation_date", "creation_date"), ("fetch_date", "fetch_date"))
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Fetch
|
||||||
|
fields = ["status", "object_id", "url"]
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 2.1.7 on 2019-04-17 14:57
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import funkwhale_api.federation.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('federation', '0017_auto_20190130_0926'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Fetch',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('url', models.URLField(db_index=True, max_length=500)),
|
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('fetch_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('object_id', models.IntegerField(null=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('errored', 'Errored'), ('finished', 'Finished'), ('skipped', 'Skipped')], default='pending', max_length=20)),
|
||||||
|
('detail', django.contrib.postgres.fields.jsonb.JSONField(default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000)),
|
||||||
|
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fetches', to='federation.Actor')),
|
||||||
|
('object_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -288,6 +288,55 @@ class Actor(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FETCH_STATUSES = [
|
||||||
|
("pending", "Pending"),
|
||||||
|
("errored", "Errored"),
|
||||||
|
("finished", "Finished"),
|
||||||
|
("skipped", "Skipped"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FetchQuerySet(models.QuerySet):
|
||||||
|
def get_for_object(self, object):
|
||||||
|
content_type = ContentType.objects.get_for_model(object)
|
||||||
|
return self.filter(object_content_type=content_type, object_id=object.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class Fetch(models.Model):
|
||||||
|
url = models.URLField(max_length=500, db_index=True)
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
fetch_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
object_id = models.IntegerField(null=True)
|
||||||
|
object_content_type = models.ForeignKey(
|
||||||
|
ContentType, null=True, on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
object = GenericForeignKey("object_content_type", "object_id")
|
||||||
|
status = models.CharField(default="pending", choices=FETCH_STATUSES, max_length=20)
|
||||||
|
detail = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder)
|
||||||
|
actor = models.ForeignKey(Actor, related_name="fetches", on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
objects = FetchQuerySet.as_manager()
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
if not self.url and self.object:
|
||||||
|
self.url = self.object.fid
|
||||||
|
|
||||||
|
super().save(**kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serializers(self):
|
||||||
|
from . import contexts
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
return {
|
||||||
|
contexts.FW.Artist: serializers.ArtistSerializer,
|
||||||
|
contexts.FW.Album: serializers.AlbumSerializer,
|
||||||
|
contexts.FW.Track: serializers.TrackSerializer,
|
||||||
|
contexts.AS.Audio: serializers.UploadSerializer,
|
||||||
|
contexts.FW.Library: serializers.LibrarySerializer,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class InboxItem(models.Model):
|
class InboxItem(models.Model):
|
||||||
"""
|
"""
|
||||||
Store activities binding to local actors, with read/unread status.
|
Store activities binding to local actors, with read/unread status.
|
||||||
|
|
|
@ -771,6 +771,7 @@ class ArtistSerializer(MusicEntitySerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
model = music_models.Artist
|
||||||
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
|
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
|
@ -804,6 +805,7 @@ class AlbumSerializer(MusicEntitySerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
model = music_models.Album
|
||||||
jsonld_mapping = funkwhale_utils.concat_dicts(
|
jsonld_mapping = funkwhale_utils.concat_dicts(
|
||||||
MUSIC_ENTITY_JSONLD_MAPPING,
|
MUSIC_ENTITY_JSONLD_MAPPING,
|
||||||
{
|
{
|
||||||
|
@ -863,6 +865,7 @@ class TrackSerializer(MusicEntitySerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
model = music_models.Track
|
||||||
jsonld_mapping = funkwhale_utils.concat_dicts(
|
jsonld_mapping = funkwhale_utils.concat_dicts(
|
||||||
MUSIC_ENTITY_JSONLD_MAPPING,
|
MUSIC_ENTITY_JSONLD_MAPPING,
|
||||||
{
|
{
|
||||||
|
@ -970,6 +973,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
|
||||||
track = TrackSerializer(required=True)
|
track = TrackSerializer(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
model = music_models.Upload
|
||||||
jsonld_mapping = {
|
jsonld_mapping = {
|
||||||
"track": jsonld.first_obj(contexts.FW.track),
|
"track": jsonld.first_obj(contexts.FW.track),
|
||||||
"library": jsonld.first_id(contexts.FW.library),
|
"library": jsonld.first_id(contexts.FW.library),
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Q, F
|
from django.db.models import Q, F
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
@ -16,6 +18,7 @@ from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
|
|
||||||
from . import actors
|
from . import actors
|
||||||
|
from . import jsonld
|
||||||
from . import keys
|
from . import keys
|
||||||
from . import models, signing
|
from . import models, signing
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
@ -278,3 +281,83 @@ def rotate_actor_key(actor):
|
||||||
actor.private_key = pair[0].decode()
|
actor.private_key = pair[0].decode()
|
||||||
actor.public_key = pair[1].decode()
|
actor.public_key = pair[1].decode()
|
||||||
actor.save(update_fields=["private_key", "public_key"])
|
actor.save(update_fields=["private_key", "public_key"])
|
||||||
|
|
||||||
|
|
||||||
|
@celery.app.task(name="federation.fetch")
|
||||||
|
@transaction.atomic
|
||||||
|
@celery.require_instance(
|
||||||
|
models.Fetch.objects.filter(status="pending").select_related("actor"), "fetch"
|
||||||
|
)
|
||||||
|
def fetch(fetch):
|
||||||
|
actor = fetch.actor
|
||||||
|
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||||
|
|
||||||
|
def error(code, **kwargs):
|
||||||
|
fetch.status = "errored"
|
||||||
|
fetch.fetch_date = timezone.now()
|
||||||
|
fetch.detail = {"error_code": code}
|
||||||
|
fetch.detail.update(kwargs)
|
||||||
|
fetch.save(update_fields=["fetch_date", "status", "detail"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = session.get_session().get(
|
||||||
|
auth=auth,
|
||||||
|
url=fetch.url,
|
||||||
|
timeout=5,
|
||||||
|
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||||
|
headers={"Content-Type": "application/activity+json"},
|
||||||
|
)
|
||||||
|
logger.debug("Remote answered with %s", response.status_code)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
return error("http", status_code=e.response.status_code if e.response else None)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return error("timeout")
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
return error("connection", message=str(e))
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return error("request", message=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return error("unhandled", message=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
return error("invalid_json")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = jsonld.expand(payload)
|
||||||
|
except ValueError:
|
||||||
|
return error("invalid_jsonld")
|
||||||
|
|
||||||
|
try:
|
||||||
|
type = doc.get("@type", [])[0]
|
||||||
|
except IndexError:
|
||||||
|
return error("missing_jsonld_type")
|
||||||
|
try:
|
||||||
|
serializer_class = fetch.serializers[type]
|
||||||
|
model = serializer_class.Meta.model
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
fetch.status = "skipped"
|
||||||
|
fetch.fetch_date = timezone.now()
|
||||||
|
fetch.detail = {"reason": "unhandled_type", "type": type}
|
||||||
|
return fetch.save(update_fields=["fetch_date", "status", "detail"])
|
||||||
|
try:
|
||||||
|
id = doc.get("@id")
|
||||||
|
except IndexError:
|
||||||
|
existing = None
|
||||||
|
else:
|
||||||
|
existing = model.objects.filter(fid=id).first()
|
||||||
|
|
||||||
|
serializer = serializer_class(existing, data=payload)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return error("validation", validation_errors=serializer.errors)
|
||||||
|
try:
|
||||||
|
serializer.save()
|
||||||
|
except Exception as e:
|
||||||
|
error("save", message=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
fetch.status = "finished"
|
||||||
|
fetch.fetch_date = timezone.now()
|
||||||
|
return fetch.save(update_fields=["fetch_date", "status"])
|
||||||
|
|
|
@ -121,3 +121,13 @@ def get_domain_query_from_url(domain, url_field="fid"):
|
||||||
**{"{}__startswith".format(url_field): "https://{}/".format(domain)}
|
**{"{}__startswith".format(url_field): "https://{}/".format(domain)}
|
||||||
)
|
)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def is_local(url):
|
||||||
|
if not url:
|
||||||
|
return True
|
||||||
|
|
||||||
|
d = settings.FEDERATION_HOSTNAME
|
||||||
|
return url.startswith("http://{}/".format(d)) or url.startswith(
|
||||||
|
"https://{}/".format(d)
|
||||||
|
)
|
||||||
|
|
|
@ -117,13 +117,7 @@ class APIModelMixin(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_local(self):
|
def is_local(self):
|
||||||
if not self.fid:
|
return federation_utils.is_local(self.fid)
|
||||||
return True
|
|
||||||
|
|
||||||
d = settings.FEDERATION_HOSTNAME
|
|
||||||
return self.fid.startswith("http://{}/".format(d)) or self.fid.startswith(
|
|
||||||
"https://{}/".format(d)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain_name(self):
|
def domain_name(self):
|
||||||
|
|
|
@ -22,6 +22,7 @@ from funkwhale_api.common import views as common_views
|
||||||
from funkwhale_api.federation.authentication import SignatureAuthentication
|
from funkwhale_api.federation.authentication import SignatureAuthentication
|
||||||
from funkwhale_api.federation import actors
|
from funkwhale_api.federation import actors
|
||||||
from funkwhale_api.federation import api_serializers as federation_api_serializers
|
from funkwhale_api.federation import api_serializers as federation_api_serializers
|
||||||
|
from funkwhale_api.federation import decorators as federation_decorators
|
||||||
from funkwhale_api.federation import routes
|
from funkwhale_api.federation import routes
|
||||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||||
|
|
||||||
|
@ -70,6 +71,7 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV
|
||||||
filterset_class = filters.ArtistFilter
|
filterset_class = filters.ArtistFilter
|
||||||
ordering_fields = ("id", "name", "creation_date")
|
ordering_fields = ("id", "name", "creation_date")
|
||||||
|
|
||||||
|
fetches = federation_decorators.fetches_route()
|
||||||
mutations = common_decorators.mutations_route(types=["update"])
|
mutations = common_decorators.mutations_route(types=["update"])
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
@ -100,6 +102,7 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi
|
||||||
ordering_fields = ("creation_date", "release_date", "title")
|
ordering_fields = ("creation_date", "release_date", "title")
|
||||||
filterset_class = filters.AlbumFilter
|
filterset_class = filters.AlbumFilter
|
||||||
|
|
||||||
|
fetches = federation_decorators.fetches_route()
|
||||||
mutations = common_decorators.mutations_route(types=["update"])
|
mutations = common_decorators.mutations_route(types=["update"])
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
@ -201,7 +204,7 @@ class TrackViewSet(
|
||||||
"disc_number",
|
"disc_number",
|
||||||
"artist__name",
|
"artist__name",
|
||||||
)
|
)
|
||||||
|
fetches = federation_decorators.fetches_route()
|
||||||
mutations = common_decorators.mutations_route(types=["update"])
|
mutations = common_decorators.mutations_route(types=["update"])
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
@ -437,6 +440,8 @@ class UploadViewSet(
|
||||||
"artist__name",
|
"artist__name",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fetches = federation_decorators.fetches_route()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
return qs.filter(library__actor=self.request.user.actor)
|
return qs.filter(library__actor=self.request.user.actor)
|
||||||
|
|
|
@ -167,3 +167,15 @@ def test_user_can_update_read_status_of_inbox_item(factories, logged_in_api_clie
|
||||||
ii.refresh_from_db()
|
ii.refresh_from_db()
|
||||||
|
|
||||||
assert ii.is_read is True
|
assert ii.is_read is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_detail_fetch(logged_in_api_client, factories):
|
||||||
|
fetch = factories["federation.Fetch"](url="http://test.object")
|
||||||
|
url = reverse("api:v1:federation:fetches-detail", kwargs={"pk": fetch.pk})
|
||||||
|
|
||||||
|
response = logged_in_api_client.get(url)
|
||||||
|
|
||||||
|
expected = api_serializers.FetchSerializer(fetch).data
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from funkwhale_api.music import models as music_models
|
||||||
|
|
||||||
|
from funkwhale_api.federation import api_serializers
|
||||||
|
from funkwhale_api.federation import decorators
|
||||||
|
from funkwhale_api.federation import models
|
||||||
|
from funkwhale_api.federation import tasks
|
||||||
|
|
||||||
|
|
||||||
|
class V(viewsets.ModelViewSet):
|
||||||
|
queryset = music_models.Track.objects.all()
|
||||||
|
fetches = decorators.fetches_route()
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetches_route_create(factories, api_request, mocker):
|
||||||
|
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
|
||||||
|
user = factories["users.User"]()
|
||||||
|
actor = user.create_actor()
|
||||||
|
track = factories["music.Track"]()
|
||||||
|
view = V.as_view({"post": "fetches"})
|
||||||
|
|
||||||
|
request = api_request.post("/", format="json")
|
||||||
|
setattr(request, "user", user)
|
||||||
|
setattr(request, "session", {})
|
||||||
|
response = view(request, pk=track.pk)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
fetch = models.Fetch.objects.get_for_object(track).latest("id")
|
||||||
|
on_commit.assert_called_once_with(tasks.fetch.delay, fetch_id=fetch.pk)
|
||||||
|
|
||||||
|
assert fetch.url == track.fid
|
||||||
|
assert fetch.object == track
|
||||||
|
assert fetch.status == "pending"
|
||||||
|
assert fetch.actor == actor
|
||||||
|
|
||||||
|
expected = api_serializers.FetchSerializer(fetch).data
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetches_route_create_local(factories, api_request, mocker, settings):
|
||||||
|
user = factories["users.User"]()
|
||||||
|
user.create_actor()
|
||||||
|
track = factories["music.Track"](
|
||||||
|
fid="https://{}/test".format(settings.FEDERATION_HOSTNAME)
|
||||||
|
)
|
||||||
|
view = V.as_view({"post": "fetches"})
|
||||||
|
|
||||||
|
request = api_request.post("/", format="json")
|
||||||
|
setattr(request, "user", user)
|
||||||
|
setattr(request, "session", {})
|
||||||
|
response = view(request, pk=track.pk)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetches_route_list(factories, api_request, mocker):
|
||||||
|
user = factories["users.User"]()
|
||||||
|
user.create_actor()
|
||||||
|
track = factories["music.Track"]()
|
||||||
|
fetches = [
|
||||||
|
factories["federation.Fetch"](object=track),
|
||||||
|
factories["federation.Fetch"](object=track),
|
||||||
|
]
|
||||||
|
view = V.as_view({"get": "fetches"})
|
||||||
|
|
||||||
|
request = api_request.get("/", format="json")
|
||||||
|
setattr(request, "user", user)
|
||||||
|
setattr(request, "session", {})
|
||||||
|
expected = {
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"count": 2,
|
||||||
|
"results": api_serializers.FetchSerializer(reversed(fetches), many=True).data,
|
||||||
|
}
|
||||||
|
|
||||||
|
request = api_request.get("/")
|
||||||
|
response = view(request, pk=track.pk)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
|
@ -164,3 +164,12 @@ def test_actor_can_manage_domain_service_actor(mocker, factories):
|
||||||
obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id))
|
obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id))
|
||||||
|
|
||||||
assert actor.can_manage(obj) is True
|
assert actor.can_manage(obj) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_create_fetch_for_object(factories):
|
||||||
|
track = factories["music.Track"](fid="http://test.domain")
|
||||||
|
fetch = factories["federation.Fetch"](object=track)
|
||||||
|
assert fetch.url == "http://test.domain"
|
||||||
|
assert fetch.status == "pending"
|
||||||
|
assert fetch.detail == {}
|
||||||
|
assert fetch.object == track
|
||||||
|
|
|
@ -5,6 +5,7 @@ import pytest
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from funkwhale_api.federation import jsonld
|
||||||
from funkwhale_api.federation import models
|
from funkwhale_api.federation import models
|
||||||
from funkwhale_api.federation import serializers
|
from funkwhale_api.federation import serializers
|
||||||
from funkwhale_api.federation import tasks
|
from funkwhale_api.federation import tasks
|
||||||
|
@ -332,3 +333,60 @@ def test_rotate_actor_key(factories, settings, mocker):
|
||||||
|
|
||||||
assert actor.public_key == "public"
|
assert actor.public_key == "public"
|
||||||
assert actor.private_key == "private"
|
assert actor.private_key == "private"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_skipped(factories, r_mock):
|
||||||
|
url = "https://fetch.object"
|
||||||
|
fetch = factories["federation.Fetch"](url=url)
|
||||||
|
payload = {"@context": jsonld.get_default_context(), "type": "Unhandled"}
|
||||||
|
r_mock.get(url, json=payload)
|
||||||
|
|
||||||
|
tasks.fetch(fetch_id=fetch.pk)
|
||||||
|
|
||||||
|
fetch.refresh_from_db()
|
||||||
|
|
||||||
|
assert fetch.status == "skipped"
|
||||||
|
assert fetch.detail["reason"] == "unhandled_type"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"r_mock_args, expected_error_code",
|
||||||
|
[
|
||||||
|
({"json": {"type": "Unhandled"}}, "invalid_jsonld"),
|
||||||
|
({"json": {"@context": jsonld.get_default_context()}}, "invalid_jsonld"),
|
||||||
|
({"text": "invalidjson"}, "invalid_json"),
|
||||||
|
({"status_code": 404}, "http"),
|
||||||
|
({"status_code": 500}, "http"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fetch_errored(factories, r_mock_args, expected_error_code, r_mock):
|
||||||
|
url = "https://fetch.object"
|
||||||
|
fetch = factories["federation.Fetch"](url=url)
|
||||||
|
r_mock.get(url, **r_mock_args)
|
||||||
|
|
||||||
|
tasks.fetch(fetch_id=fetch.pk)
|
||||||
|
|
||||||
|
fetch.refresh_from_db()
|
||||||
|
|
||||||
|
assert fetch.status == "errored"
|
||||||
|
assert fetch.detail["error_code"] == expected_error_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_success(factories, r_mock, mocker):
|
||||||
|
artist = factories["music.Artist"]()
|
||||||
|
fetch = factories["federation.Fetch"](url=artist.fid)
|
||||||
|
payload = serializers.ArtistSerializer(artist).data
|
||||||
|
init = mocker.spy(serializers.ArtistSerializer, "__init__")
|
||||||
|
save = mocker.spy(serializers.ArtistSerializer, "save")
|
||||||
|
|
||||||
|
r_mock.get(artist.fid, json=payload)
|
||||||
|
|
||||||
|
tasks.fetch(fetch_id=fetch.pk)
|
||||||
|
|
||||||
|
fetch.refresh_from_db()
|
||||||
|
payload["@context"].append("https://funkwhale.audio/ns")
|
||||||
|
assert fetch.status == "finished"
|
||||||
|
assert init.call_count == 1
|
||||||
|
assert init.call_args[0][1] == artist
|
||||||
|
assert init.call_args[1]["data"] == payload
|
||||||
|
assert save.call_count == 1
|
||||||
|
|
|
@ -0,0 +1,157 @@
|
||||||
|
<template>
|
||||||
|
<div @click="createFetch" role="button">
|
||||||
|
<div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<modal class="small" :show.sync="showModal">
|
||||||
|
<h3 class="header">
|
||||||
|
<translate translate-context="Popup/*/Title">Refreshing object from remote…</translate>
|
||||||
|
</h3>
|
||||||
|
<div class="scrolling content">
|
||||||
|
<template v-if="fetch && fetch.status != 'pending'">
|
||||||
|
<div v-if="fetch.status === 'skipped'" class="ui message">
|
||||||
|
<div class="header"><translate translate-context="Popup/*/Message.Title">Refresh was skipped</translate></div>
|
||||||
|
<p><translate translate-context="Popup/*/Message.Content">The remote server answered, but returned data was unsupported by Funkwhale.</translate></p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="fetch.status === 'finished'" class="ui success message">
|
||||||
|
<div class="header"><translate translate-context="Popup/*/Message.Title">Refresh successful</translate></div>
|
||||||
|
<p><translate translate-context="Popup/*/Message.Content">Data was refreshed successfully from remote server.</translate></p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="fetch.status === 'errored'" class="ui error message">
|
||||||
|
<div class="header"><translate translate-context="Popup/*/Message.Title">Refresh error</translate></div>
|
||||||
|
<p><translate translate-context="Popup/*/Message.Content">An error occured while trying to refresh data:</translate></p>
|
||||||
|
<table class="ui very basic collapsing celled table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<translate translate-context="Popup/Import/Table.Label/Noun">Error type</translate>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ fetch.detail.error_code }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<translate translate-context="Popup/Import/Table.Label/Noun">Error detail</translate>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<translate
|
||||||
|
v-if="fetch.detail.error_code === 'http' && fetch.detail.status_code"
|
||||||
|
:translate-params="{status: fetch.detail.status_code}"
|
||||||
|
translate-context="*/*/Error">The remote server answered with HTTP %{ status }</translate>
|
||||||
|
<translate
|
||||||
|
v-else-if="['http', 'request'].indexOf(fetch.detail.error_code) > -1"
|
||||||
|
translate-context="*/*/Error">An HTTP error occured while contacting the remote server</translate>
|
||||||
|
<translate
|
||||||
|
v-else-if="fetch.detail.error_code === 'timeout'"
|
||||||
|
translate-context="*/*/Error">The remote server didn't answered fast enough</translate>
|
||||||
|
<translate
|
||||||
|
v-else-if="fetch.detail.error_code === 'connection'"
|
||||||
|
translate-context="*/*/Error">Impossible to connect to the remote server</translate>
|
||||||
|
<translate
|
||||||
|
v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].indexOf(fetch.detail.error_code) > -1"
|
||||||
|
translate-context="*/*/Error">The return server returned invalid JSON or JSON-LD data</translate>
|
||||||
|
<translate v-else-if="fetch.detail.error_code === 'validation'" translate-context="*/*/Error">Data returned by the remote server had invalid or missing attributes</translate>
|
||||||
|
<translate v-else-if="fetch.detail.error_code === 'unhandled'" translate-context="*/*/Error">Unknowkn error</translate>
|
||||||
|
<translate v-else translate-context="*/*/Error">Unknowkn error</translate>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="isCreatingFetch" class="ui active inverted dimmer">
|
||||||
|
<div class="ui text loader">
|
||||||
|
<translate translate-context="Popup/*/Loading.Title">Requesting a fetch…</translate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isWaitingFetch" class="ui active inverted dimmer">
|
||||||
|
<div class="ui text loader">
|
||||||
|
<translate translate-context="Popup/*/Loading.Title">Waiting for result…</translate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
|
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving settings</translate></div>
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="fetch && fetch.status === 'pending' && pollsCount >= maxPolls" class="ui warning message">
|
||||||
|
<div class="header"><translate translate-context="Popup/*/Message.Title">Refresh pending</translate></div>
|
||||||
|
<p><translate translate-context="Popup/*/Message.Content">Refresh request wasn't proceed in time by our server. It will be processed later.</translate></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div role="button" class="ui cancel button">
|
||||||
|
<translate translate-context="*/*/Button.Label/Verb">Close</translate>
|
||||||
|
</div>
|
||||||
|
<div role="button" @click="showModal = false; $emit('refresh')" class="ui confirm green button" v-if="fetch && fetch.status === 'finished'">
|
||||||
|
<translate translate-context="*/*/Button.Label/Verb">Close and reload page</translate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from "axios"
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['url'],
|
||||||
|
components: {
|
||||||
|
Modal
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
fetch: null,
|
||||||
|
isCreatingFetch: false,
|
||||||
|
errors: [],
|
||||||
|
showModal: false,
|
||||||
|
isWaitingFetch: false,
|
||||||
|
maxPolls: 15,
|
||||||
|
pollsCount: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createFetch () {
|
||||||
|
let self = this
|
||||||
|
this.fetch = null
|
||||||
|
this.pollsCount = 0
|
||||||
|
this.errors = []
|
||||||
|
this.isCreatingFetch = true
|
||||||
|
this.isWaitingFetch = false
|
||||||
|
self.showModal = true
|
||||||
|
axios.post(this.url).then((response) => {
|
||||||
|
self.isCreatingFetch = false
|
||||||
|
self.fetch = response.data
|
||||||
|
self.pollFetch()
|
||||||
|
}, (error) => {
|
||||||
|
self.isCreatingFetch = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
pollFetch () {
|
||||||
|
this.isWaitingFetch = true
|
||||||
|
this.pollsCount += 1
|
||||||
|
let url = `federation/fetches/${this.fetch.id}/`
|
||||||
|
let self = this
|
||||||
|
self.showModal = true
|
||||||
|
axios.get(url).then((response) => {
|
||||||
|
self.isCreatingFetch = false
|
||||||
|
self.fetch = response.data
|
||||||
|
if (self.fetch.status === 'pending' && self.pollsCount < self.maxPolls) {
|
||||||
|
setTimeout(() => {
|
||||||
|
self.pollFetch()
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
self.isWaitingFetch = false
|
||||||
|
}
|
||||||
|
}, (error) => {
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
self.isWaitingFetch = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -23,6 +23,7 @@ export default {
|
||||||
if (this.control) {
|
if (this.control) {
|
||||||
$(this.$el).modal('hide')
|
$(this.$el).modal('hide')
|
||||||
}
|
}
|
||||||
|
$(this.$el).remove()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initModal () {
|
initModal () {
|
||||||
|
|
|
@ -46,6 +46,10 @@
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>
|
<translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>
|
||||||
</a>
|
</a>
|
||||||
|
<fetch-button @refresh="fetchData" v-if="!object.is_local" class="basic item" :url="`albums/${object.id}/fetches/`">
|
||||||
|
<i class="refresh icon"></i>
|
||||||
|
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>
|
||||||
|
</fetch-button>
|
||||||
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
||||||
|
@ -264,10 +268,14 @@
|
||||||
<script>
|
<script>
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import logger from "@/logging"
|
import logger from "@/logging"
|
||||||
|
import FetchButton from "@/components/federation/FetchButton"
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ["id"],
|
props: ["id"],
|
||||||
|
components: {
|
||||||
|
FetchButton
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|
|
@ -45,6 +45,10 @@
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>
|
<translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>
|
||||||
</a>
|
</a>
|
||||||
|
<fetch-button @refresh="fetchData" v-if="!object.is_local" class="basic item" :url="`artists/${object.id}/fetches/`">
|
||||||
|
<i class="refresh icon"></i>
|
||||||
|
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>
|
||||||
|
</fetch-button>
|
||||||
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
||||||
|
@ -264,9 +268,13 @@
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import logger from "@/logging"
|
import logger from "@/logging"
|
||||||
|
|
||||||
|
import FetchButton from "@/components/federation/FetchButton"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ["id"],
|
props: ["id"],
|
||||||
|
components: {
|
||||||
|
FetchButton
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|
|
@ -45,6 +45,10 @@
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>
|
<translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>
|
||||||
</a>
|
</a>
|
||||||
|
<fetch-button @refresh="fetchData" v-if="!object.is_local" class="basic item" :url="`tracks/${object.id}/fetches/`">
|
||||||
|
<i class="refresh icon"></i>
|
||||||
|
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>
|
||||||
|
</fetch-button>
|
||||||
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
||||||
|
@ -306,10 +310,14 @@
|
||||||
<script>
|
<script>
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import logger from "@/logging"
|
import logger from "@/logging"
|
||||||
|
import FetchButton from "@/components/federation/FetchButton"
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ["id"],
|
props: ["id"],
|
||||||
|
components: {
|
||||||
|
FetchButton
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|
Loading…
Reference in New Issue