Logic to refetch remote entities

This commit is contained in:
Eliot Berriot 2019-04-18 14:37:17 +02:00
parent 63b1007596
commit cdc617be27
23 changed files with 632 additions and 9 deletions

View File

@ -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"]

View File

@ -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",
]

View File

@ -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")

View File

@ -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]

View File

@ -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)

View File

@ -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)

View File

@ -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"]

View File

@ -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')),
],
),
]

View File

@ -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.

View File

@ -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),

View File

@ -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"])

View File

@ -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)
)

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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 () {

View File

@ -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>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>&nbsp;
</a> </a>
<fetch-button @refresh="fetchData" v-if="!object.is_local" class="basic item" :url="`albums/${object.id}/fetches/`">
<i class="refresh icon"></i>&nbsp;
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>&nbsp;
</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>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>&nbsp;
@ -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,

View File

@ -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>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>&nbsp;
</a> </a>
<fetch-button @refresh="fetchData" v-if="!object.is_local" class="basic item" :url="`artists/${object.id}/fetches/`">
<i class="refresh icon"></i>&nbsp;
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>&nbsp;
</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>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>&nbsp;
@ -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,

View File

@ -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>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate>&nbsp;
</a> </a>
<fetch-button @refresh="fetchData" v-if="!object.is_local" class="basic item" :url="`tracks/${object.id}/fetches/`">
<i class="refresh icon"></i>&nbsp;
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>&nbsp;
</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>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>&nbsp;
@ -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,