Merge branch 'domaind-dedicated-table' into 'develop'

Domaind dedicated table

See merge request funkwhale/funkwhale!504
This commit is contained in:
Eliot Berriot 2018-12-27 21:09:55 +01:00
commit 54c0987b86
48 changed files with 1334 additions and 330 deletions

View File

@ -69,6 +69,8 @@ else:
FUNKWHALE_HOSTNAME = _parsed.netloc FUNKWHALE_HOSTNAME = _parsed.netloc
FUNKWHALE_PROTOCOL = _parsed.scheme FUNKWHALE_PROTOCOL = _parsed.scheme
FUNKWHALE_PROTOCOL = FUNKWHALE_PROTOCOL.lower()
FUNKWHALE_HOSTNAME = FUNKWHALE_HOSTNAME.lower()
FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
FUNKWHALE_SPA_HTML_ROOT = env( FUNKWHALE_SPA_HTML_ROOT = env(
"FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/" "FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/"
@ -83,7 +85,7 @@ APP_NAME = "Funkwhale"
# XXX: deprecated, see #186 # XXX: deprecated, see #186
FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True) FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME) FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME).lower()
# XXX: deprecated, see #186 # XXX: deprecated, see #186
FEDERATION_COLLECTION_PAGE_SIZE = env.int("FEDERATION_COLLECTION_PAGE_SIZE", default=50) FEDERATION_COLLECTION_PAGE_SIZE = env.int("FEDERATION_COLLECTION_PAGE_SIZE", default=50)
# XXX: deprecated, see #186 # XXX: deprecated, see #186

View File

@ -5,7 +5,7 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
logger = logging.getLogger(__file__) logger = logging.getLogger(__name__)
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
group_add = async_to_sync(channel_layer.group_add) group_add = async_to_sync(channel_layer.group_add)

View File

@ -10,7 +10,6 @@ from funkwhale_api.users import models
mapping = { mapping = {
"dynamic_preferences.change_globalpreferencemodel": "settings", "dynamic_preferences.change_globalpreferencemodel": "settings",
"music.add_importbatch": "library", "music.add_importbatch": "library",
"federation.change_library": "federation",
} }

View File

@ -42,23 +42,39 @@ ACTIVITY_TYPES = [
"View", "View",
] ]
FUNKWHALE_OBJECT_TYPES = [
OBJECT_TYPES = [ ("Domain", "Domain"),
("Artist", "Artist"),
("Album", "Album"),
("Track", "Track"),
("Library", "Library"),
]
OBJECT_TYPES = (
[
"Application",
"Article", "Article",
"Audio", "Audio",
"Collection", "Collection",
"Document", "Document",
"Event", "Event",
"Group",
"Image", "Image",
"Note", "Note",
"Object",
"OrderedCollection", "OrderedCollection",
"Organization",
"Page", "Page",
"Person",
"Place", "Place",
"Profile", "Profile",
"Relationship", "Relationship",
"Service",
"Tombstone", "Tombstone",
"Video", "Video",
] + ACTIVITY_TYPES ]
+ ACTIVITY_TYPES
+ FUNKWHALE_OBJECT_TYPES
)
BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
@ -386,15 +402,3 @@ def get_actors_from_audience(urls):
if not final_query: if not final_query:
return models.Actor.objects.none() return models.Actor.objects.none()
return models.Actor.objects.filter(final_query) return models.Actor.objects.filter(final_query)
def get_inbox_urls(actor_queryset):
"""
Given an actor queryset, returns a deduplicated set containing
all inbox or shared inbox urls where we should deliver our payloads for
those actors
"""
values = actor_queryset.values("inbox_url", "shared_inbox_url")
urls = set([actor["shared_inbox_url"] or actor["inbox_url"] for actor in values])
return sorted(urls)

View File

@ -24,6 +24,12 @@ def redeliver_activities(modeladmin, request, queryset):
redeliver_activities.short_description = "Redeliver" redeliver_activities.short_description = "Redeliver"
@admin.register(models.Domain)
class DomainAdmin(admin.ModelAdmin):
list_display = ["name", "creation_date"]
search_fields = ["name"]
@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

@ -66,24 +66,39 @@ def create_user(actor):
return user_factories.UserFactory(actor=actor) return user_factories.UserFactory(actor=actor)
@registry.register
class Domain(factory.django.DjangoModelFactory):
name = factory.Faker("domain_name")
class Meta:
model = "federation.Domain"
django_get_or_create = ("name",)
@registry.register @registry.register
class ActorFactory(factory.DjangoModelFactory): class ActorFactory(factory.DjangoModelFactory):
public_key = None public_key = None
private_key = None private_key = None
preferred_username = factory.Faker("user_name") preferred_username = factory.Faker("user_name")
summary = factory.Faker("paragraph") summary = factory.Faker("paragraph")
domain = factory.Faker("domain_name") domain = factory.SubFactory(Domain)
fid = factory.LazyAttribute( fid = factory.LazyAttribute(
lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username) lambda o: "https://{}/users/{}".format(o.domain.name, o.preferred_username)
) )
followers_url = factory.LazyAttribute( followers_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}followers".format(o.domain, o.preferred_username) lambda o: "https://{}/users/{}followers".format(
o.domain.name, o.preferred_username
)
) )
inbox_url = factory.LazyAttribute( inbox_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}/inbox".format(o.domain, o.preferred_username) lambda o: "https://{}/users/{}/inbox".format(
o.domain.name, o.preferred_username
)
) )
outbox_url = factory.LazyAttribute( outbox_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}/outbox".format(o.domain, o.preferred_username) lambda o: "https://{}/users/{}/outbox".format(
o.domain.name, o.preferred_username
)
) )
class Meta: class Meta:
@ -95,7 +110,9 @@ class ActorFactory(factory.DjangoModelFactory):
return return
from funkwhale_api.users.factories import UserFactory from funkwhale_api.users.factories import UserFactory
self.domain = settings.FEDERATION_HOSTNAME self.domain = models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME
)[0]
self.save(update_fields=["domain"]) self.save(update_fields=["domain"])
if not create: if not create:
if extracted and hasattr(extracted, "pk"): if extracted and hasattr(extracted, "pk"):

View File

@ -0,0 +1,23 @@
# Generated by Django 2.0.9 on 2018-12-26 19:35
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [("federation", "0012_auto_20180920_1803")]
operations = [
migrations.AlterField(
model_name="actor",
name="private_key",
field=models.TextField(blank=True, max_length=5000, null=True),
),
migrations.AlterField(
model_name="actor",
name="public_key",
field=models.TextField(blank=True, max_length=5000, null=True),
),
]

View File

@ -0,0 +1,46 @@
# Generated by Django 2.0.9 on 2018-12-05 09:58
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [("federation", "0013_auto_20181226_1935")]
operations = [
migrations.CreateModel(
name="Domain",
fields=[
(
"name",
models.CharField(max_length=255, primary_key=True, serialize=False),
),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
],
),
migrations.AlterField(
model_name="actor",
name="domain",
field=models.CharField(max_length=1000, null=True),
),
migrations.RenameField("actor", "domain", "old_domain"),
migrations.AddField(
model_name="actor",
name="domain",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="actors",
to="federation.Domain",
),
),
migrations.AlterUniqueTogether(name="actor", unique_together=set()),
migrations.AlterUniqueTogether(
name="actor", unique_together={("domain", "preferred_username")}
),
]

View File

@ -0,0 +1,56 @@
# Generated by Django 2.0.9 on 2018-11-14 08:55
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
def populate_domains(apps, schema_editor):
Domain = apps.get_model("federation", "Domain")
Actor = apps.get_model("federation", "Actor")
domains = set(
[v.lower() for v in Actor.objects.values_list("old_domain", flat=True)]
)
for domain in sorted(domains):
print("Populating domain {}...".format(domain))
first_actor = (
Actor.objects.order_by("creation_date")
.exclude(creation_date=None)
.filter(old_domain__iexact=domain)
.first()
)
if first_actor:
first_seen = first_actor.creation_date
else:
first_seen = django.utils.timezone.now()
Domain.objects.update_or_create(
name=domain, defaults={"creation_date": first_seen}
)
for domain in Domain.objects.all():
Actor.objects.filter(old_domain__iexact=domain.name).update(domain=domain)
def skip(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [("federation", "0014_auto_20181205_0958")]
operations = [
migrations.RunPython(populate_domains, skip),
migrations.AlterField(
model_name="actor",
name="domain",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="actors",
to="federation.Domain",
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 2.0.9 on 2018-12-27 16:05
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import funkwhale_api.federation.models
class Migration(migrations.Migration):
dependencies = [("federation", "0015_populate_domains")]
operations = [
migrations.AddField(
model_name="domain",
name="nodeinfo",
field=django.contrib.postgres.fields.jsonb.JSONField(
default=funkwhale_api.federation.models.empty_dict, max_length=50000
),
),
migrations.AddField(
model_name="domain",
name="nodeinfo_fetch_date",
field=models.DateTimeField(blank=True, default=None, null=True),
),
]

View File

@ -62,6 +62,81 @@ class ActorQuerySet(models.QuerySet):
return qs return qs
class DomainQuerySet(models.QuerySet):
def external(self):
return self.exclude(pk=settings.FEDERATION_HOSTNAME)
def with_last_activity_date(self):
activities = Activity.objects.filter(
actor__domain=models.OuterRef("pk")
).order_by("-creation_date")
return self.annotate(
last_activity_date=models.Subquery(activities.values("creation_date")[:1])
)
def with_actors_count(self):
return self.annotate(actors_count=models.Count("actors", distinct=True))
def with_outbox_activities_count(self):
return self.annotate(
outbox_activities_count=models.Count("actors__outbox_activities")
)
class Domain(models.Model):
name = models.CharField(primary_key=True, max_length=255)
creation_date = models.DateTimeField(default=timezone.now)
nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True)
nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True)
objects = DomainQuerySet.as_manager()
def __str__(self):
return self.name
def save(self, **kwargs):
lowercase_fields = ["name"]
for field in lowercase_fields:
v = getattr(self, field, None)
if v:
setattr(self, field, v.lower())
super().save(**kwargs)
def get_stats(self):
from funkwhale_api.music import models as music_models
data = Domain.objects.filter(pk=self.pk).aggregate(
actors=models.Count("actors", distinct=True),
outbox_activities=models.Count("actors__outbox_activities", distinct=True),
libraries=models.Count("actors__libraries", distinct=True),
uploads=models.Count("actors__libraries__uploads", distinct=True),
received_library_follows=models.Count(
"actors__libraries__received_follows", distinct=True
),
emitted_library_follows=models.Count(
"actors__library_follows", distinct=True
),
)
data["artists"] = music_models.Artist.objects.filter(
from_activity__actor__domain_id=self.pk
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor__domain_id=self.pk
).count()
data["tracks"] = music_models.Track.objects.filter(
from_activity__actor__domain_id=self.pk
).count()
uploads = music_models.Upload.objects.filter(library__actor__domain_id=self.pk)
data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
data["media_downloaded_size"] = (
uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
)
return data
class Actor(models.Model): class Actor(models.Model):
ap_type = "Actor" ap_type = "Actor"
@ -74,7 +149,7 @@ class Actor(models.Model):
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True) shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25) type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
name = models.CharField(max_length=200, null=True, blank=True) name = models.CharField(max_length=200, null=True, blank=True)
domain = models.CharField(max_length=1000) domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
summary = models.CharField(max_length=500, null=True, blank=True) summary = models.CharField(max_length=500, null=True, blank=True)
preferred_username = models.CharField(max_length=200, null=True, blank=True) preferred_username = models.CharField(max_length=200, null=True, blank=True)
public_key = models.TextField(max_length=5000, null=True, blank=True) public_key = models.TextField(max_length=5000, null=True, blank=True)
@ -110,36 +185,9 @@ class Actor(models.Model):
def __str__(self): def __str__(self):
return "{}@{}".format(self.preferred_username, self.domain) return "{}@{}".format(self.preferred_username, self.domain)
def save(self, **kwargs):
lowercase_fields = ["domain"]
for field in lowercase_fields:
v = getattr(self, field, None)
if v:
setattr(self, field, v.lower())
super().save(**kwargs)
@property @property
def is_local(self): def is_local(self):
return self.domain == settings.FEDERATION_HOSTNAME return self.domain_id == settings.FEDERATION_HOSTNAME
@property
def is_system(self):
from . import actors
return all(
[
settings.FEDERATION_HOSTNAME == self.domain,
self.preferred_username in actors.SYSTEM_ACTORS,
]
)
@property
def system_conf(self):
from . import actors
if self.is_system:
return actors.SYSTEM_ACTORS[self.preferred_username]
def get_approved_followers(self): def get_approved_followers(self):
follows = self.received_follows.filter(approved=True) follows = self.received_follows.filter(approved=True)

View File

@ -114,7 +114,7 @@ class ActorSerializer(serializers.Serializer):
if maf is not None: if maf is not None:
kwargs["manually_approves_followers"] = maf kwargs["manually_approves_followers"] = maf
domain = urllib.parse.urlparse(kwargs["fid"]).netloc domain = urllib.parse.urlparse(kwargs["fid"]).netloc
kwargs["domain"] = domain kwargs["domain"] = models.Domain.objects.get_or_create(pk=domain)[0]
for endpoint, url in self.initial_data.get("endpoints", {}).items(): for endpoint, url in self.initial_data.get("endpoints", {}).items():
if endpoint == "sharedInbox": if endpoint == "sharedInbox":
kwargs["shared_inbox_url"] = url kwargs["shared_inbox_url"] = url
@ -888,3 +888,12 @@ class CollectionSerializer(serializers.Serializer):
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = AP_CONTEXT d["@context"] = AP_CONTEXT
return d return d
class NodeInfoLinkSerializer(serializers.Serializer):
href = serializers.URLField()
rel = serializers.URLField()
class NodeInfoSerializer(serializers.Serializer):
links = serializers.ListField(child=NodeInfoLinkSerializer(), min_length=1)

View File

@ -1,6 +1,7 @@
import datetime import datetime
import logging import logging
import os import os
import requests
from django.conf import settings from django.conf import settings
from django.db.models import Q, F from django.db.models import Q, F
@ -14,6 +15,7 @@ from funkwhale_api.music import models as music_models
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
from . import models, signing from . import models, signing
from . import serializers
from . import routes from . import routes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -147,3 +149,40 @@ def deliver_to_remote(delivery):
delivery.attempts = F("attempts") + 1 delivery.attempts = F("attempts") + 1
delivery.is_delivered = True delivery.is_delivered = True
delivery.save(update_fields=["last_attempt_date", "attempts", "is_delivered"]) delivery.save(update_fields=["last_attempt_date", "attempts", "is_delivered"])
def fetch_nodeinfo(domain_name):
s = session.get_session()
wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name)
response = s.get(
url=wellknown_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
)
response.raise_for_status()
serializer = serializers.NodeInfoSerializer(data=response.json())
serializer.is_valid(raise_exception=True)
nodeinfo_url = None
for link in serializer.validated_data["links"]:
if link["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0":
nodeinfo_url = link["href"]
break
response = s.get(
url=nodeinfo_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
)
response.raise_for_status()
return response.json()
@celery.app.task(name="federation.update_domain_nodeinfo")
@celery.require_instance(
models.Domain.objects.external(), "domain", id_kwarg_name="domain_name"
)
def update_domain_nodeinfo(domain):
now = timezone.now()
try:
nodeinfo = {"status": "ok", "payload": fetch_nodeinfo(domain.name)}
except (requests.RequestException, serializers.serializers.ValidationError) as e:
nodeinfo = {"status": "error", "error": str(e)}
domain.nodeinfo_fetch_date = now
domain.nodeinfo = nodeinfo
domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date"])

View File

@ -1,6 +1,7 @@
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.federation import models as federation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
@ -20,6 +21,14 @@ class ManageUploadFilterSet(filters.FilterSet):
fields = ["q", "track__album", "track__artist", "track"] fields = ["q", "track__album", "track__artist", "track"]
class ManageDomainFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=["name"])
class Meta:
model = federation_models.Domain
fields = ["name"]
class ManageUserFilterSet(filters.FilterSet): class ManageUserFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=["username", "email", "name"]) q = fields.SearchFilter(search_fields=["username", "email", "name"])
@ -31,10 +40,9 @@ class ManageUserFilterSet(filters.FilterSet):
"privacy_level", "privacy_level",
"is_staff", "is_staff",
"is_superuser", "is_superuser",
"permission_upload",
"permission_library", "permission_library",
"permission_settings", "permission_settings",
"permission_federation", "permission_moderation",
] ]

View File

@ -3,6 +3,7 @@ from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.federation import models as federation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
@ -168,3 +169,30 @@ class ManageInvitationActionSerializer(common_serializers.ActionSerializer):
@transaction.atomic @transaction.atomic
def handle_delete(self, objects): def handle_delete(self, objects):
return objects.delete() return objects.delete()
class ManageDomainSerializer(serializers.ModelSerializer):
actors_count = serializers.SerializerMethodField()
last_activity_date = serializers.SerializerMethodField()
outbox_activities_count = serializers.SerializerMethodField()
class Meta:
model = federation_models.Domain
fields = [
"name",
"creation_date",
"actors_count",
"last_activity_date",
"outbox_activities_count",
"nodeinfo",
"nodeinfo_fetch_date",
]
def get_actors_count(self, o):
return getattr(o, "actors_count", 0)
def get_last_activity_date(self, o):
return getattr(o, "last_activity_date", None)
def get_outbox_activities_count(self, o):
return getattr(o, "outbox_activities_count", 0)

View File

@ -3,6 +3,8 @@ from rest_framework import routers
from . import views from . import views
federation_router = routers.SimpleRouter()
federation_router.register(r"domains", views.ManageDomainViewSet, "domains")
library_router = routers.SimpleRouter() library_router = routers.SimpleRouter()
library_router.register(r"uploads", views.ManageUploadViewSet, "uploads") library_router.register(r"uploads", views.ManageUploadViewSet, "uploads")
users_router = routers.SimpleRouter() users_router = routers.SimpleRouter()
@ -10,6 +12,10 @@ users_router.register(r"users", views.ManageUserViewSet, "users")
users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
urlpatterns = [ urlpatterns = [
url(
r"^federation/",
include((federation_router.urls, "federation"), namespace="federation"),
),
url(r"^library/", include((library_router.urls, "instance"), namespace="library")), url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
url(r"^users/", include((users_router.urls, "instance"), namespace="users")), url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
] ]

View File

@ -1,7 +1,9 @@
from rest_framework import mixins, response, viewsets from rest_framework import mixins, response, viewsets
from rest_framework.decorators import list_route from rest_framework.decorators import detail_route, list_route
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
from funkwhale_api.users.permissions import HasUserPermission from funkwhale_api.users.permissions import HasUserPermission
@ -92,3 +94,39 @@ class ManageInvitationViewSet(
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 ManageDomainViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
lookup_value_regex = r"[a-zA-Z0-9\-\.]+"
queryset = (
federation_models.Domain.objects.external()
.with_last_activity_date()
.with_actors_count()
.with_outbox_activities_count()
.order_by("name")
)
serializer_class = serializers.ManageDomainSerializer
filter_class = filters.ManageDomainFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["moderation"]
ordering_fields = [
"name",
"creation_date",
"last_activity_date",
"actors_count",
"outbox_activities_count",
]
@detail_route(methods=["get"])
def nodeinfo(self, request, *args, **kwargs):
domain = self.get_object()
federation_tasks.update_domain_nodeinfo(domain_name=domain.name)
domain.refresh_from_db()
return response.Response(domain.nodeinfo, status=200)
@detail_route(methods=["get"])
def stats(self, request, *args, **kwargs):
domain = self.get_object()
return response.Response(domain.get_stats(), status=200)

View File

@ -29,7 +29,7 @@ from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from . import importers, metadata, utils from . import importers, metadata, utils
logger = logging.getLogger(__file__) logger = logging.getLogger(__name__)
def empty_dict(): def empty_dict():
@ -617,6 +617,9 @@ class UploadQuerySet(models.QuerySet):
def for_federation(self): def for_federation(self):
return self.filter(import_status="finished", mimetype__startswith="audio/") return self.filter(import_status="finished", mimetype__startswith="audio/")
def with_file(self):
return self.exclude(audio_file=None).exclude(audio_file="")
TRACK_FILE_IMPORT_STATUS_CHOICES = ( TRACK_FILE_IMPORT_STATUS_CHOICES = (
("pending", "Pending"), ("pending", "Pending"),

View File

@ -51,7 +51,7 @@ class UserAdmin(AuthUserAdmin):
"privacy_level", "privacy_level",
"permission_settings", "permission_settings",
"permission_library", "permission_library",
"permission_federation", "permission_moderation",
] ]
fieldsets = ( fieldsets = (
@ -67,10 +67,9 @@ class UserAdmin(AuthUserAdmin):
"is_active", "is_active",
"is_staff", "is_staff",
"is_superuser", "is_superuser",
"permission_upload",
"permission_library", "permission_library",
"permission_settings", "permission_settings",
"permission_federation", "permission_moderation",
) )
}, },
), ),

View File

@ -0,0 +1,26 @@
# Generated by Django 2.0.9 on 2018-12-06 10:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0012_user_upload_quota'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='permission_federation',
),
migrations.RemoveField(
model_name='user',
name='permission_upload',
),
migrations.AddField(
model_name='user',
name='permission_moderation',
field=models.BooleanField(default=False, help_text='Block/mute/remove domains, users and content', verbose_name='Moderation'),
),
]

View File

@ -34,16 +34,15 @@ def get_token():
PERMISSIONS_CONFIGURATION = { PERMISSIONS_CONFIGURATION = {
"federation": { "moderation": {
"label": "Manage library federation", "label": "Moderation",
"help_text": "Follow other instances, accept/deny library follow requests...", "help_text": "Block/mute/remove domains, users and content",
}, },
"library": { "library": {
"label": "Manage library", "label": "Manage library",
"help_text": "Manage library, delete files, tracks, artists, albums...", "help_text": "Manage library, delete files, tracks, artists, albums...",
}, },
"settings": {"label": "Manage instance-level settings", "help_text": ""}, "settings": {"label": "Manage instance-level settings", "help_text": ""},
"upload": {"label": "Upload new content to the library", "help_text": ""},
} }
PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
@ -71,9 +70,9 @@ class User(AbstractUser):
subsonic_api_token = models.CharField(blank=True, null=True, max_length=255) subsonic_api_token = models.CharField(blank=True, null=True, max_length=255)
# permissions # permissions
permission_federation = models.BooleanField( permission_moderation = models.BooleanField(
PERMISSIONS_CONFIGURATION["federation"]["label"], PERMISSIONS_CONFIGURATION["moderation"]["label"],
help_text=PERMISSIONS_CONFIGURATION["federation"]["help_text"], help_text=PERMISSIONS_CONFIGURATION["moderation"]["help_text"],
default=False, default=False,
) )
permission_library = models.BooleanField( permission_library = models.BooleanField(
@ -86,11 +85,6 @@ class User(AbstractUser):
help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"], help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"],
default=False, default=False,
) )
permission_upload = models.BooleanField(
PERMISSIONS_CONFIGURATION["upload"]["label"],
help_text=PERMISSIONS_CONFIGURATION["upload"]["help_text"],
default=False,
)
last_activity = models.DateTimeField(default=None, null=True, blank=True) last_activity = models.DateTimeField(default=None, null=True, blank=True)
@ -252,7 +246,9 @@ def get_actor_data(user):
username = federation_utils.slugify_username(user.username) username = federation_utils.slugify_username(user.username)
return { return {
"preferred_username": username, "preferred_username": username,
"domain": settings.FEDERATION_HOSTNAME, "domain": federation_models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME
)[0],
"type": "Person", "type": "Person",
"name": user.username, "name": user.username,
"manually_approves_followers": False, "manually_approves_followers": False,

View File

@ -51,7 +51,7 @@ django-taggit>=0.22,<0.23
# Until this is merged # Until this is merged
pymemoize==1.0.3 pymemoize==1.0.3
django-dynamic-preferences>=1.5,<1.6 django-dynamic-preferences>=1.7,<1.8
pyacoustid>=1.1.5,<1.2 pyacoustid>=1.1.5,<1.2
raven>=6.5,<7 raven>=6.5,<7
python-magic==0.4.15 python-magic==0.4.15

View File

@ -22,30 +22,6 @@ def test_script_command_list(command, script_name, mocker):
mocked.assert_called_once_with(command, script_name=script_name, interactive=False) mocked.assert_called_once_with(command, script_name=script_name, interactive=False)
def test_django_permissions_to_user_permissions(factories, command):
group = factories["auth.Group"](perms=["federation.change_library"])
user1 = factories["users.User"](
perms=[
"dynamic_preferences.change_globalpreferencemodel",
"music.add_importbatch",
]
)
user2 = factories["users.User"](perms=["music.add_importbatch"], groups=[group])
scripts.django_permissions_to_user_permissions.main(command)
user1.refresh_from_db()
user2.refresh_from_db()
assert user1.permission_settings is True
assert user1.permission_library is True
assert user1.permission_federation is False
assert user2.permission_settings is False
assert user2.permission_library is True
assert user2.permission_federation is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
"open_api,expected_visibility", [(True, "everyone"), (False, "instance")] "open_api,expected_visibility", [(True, "everyone"), (False, "instance")]
) )

View File

@ -12,12 +12,16 @@ from faker.providers import internet as internet_provider
import factory import factory
import pytest import pytest
from django.core.management import call_command
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache, caches from django.core.cache import cache as django_cache, caches
from django.core.files import uploadedfile from django.core.files import uploadedfile
from django.utils import timezone from django.utils import timezone
from django.test import client from django.test import client
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.db.models import QuerySet from django.db.models import QuerySet
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
@ -400,3 +404,9 @@ def spa_html(r_mock, settings):
@pytest.fixture @pytest.fixture
def no_api_auth(preferences): def no_api_auth(preferences):
preferences["common__api_authentication_required"] = False preferences["common__api_authentication_required"] = False
@pytest.fixture()
def migrator(transactional_db):
yield MigrationExecutor(connection)
call_command("migrate", interactive=False)

View File

@ -78,22 +78,6 @@ def test_get_actors_from_audience_urls(settings, db):
assert str(activity.get_actors_from_audience(urls).query) == str(expected.query) assert str(activity.get_actors_from_audience(urls).query) == str(expected.query)
def test_get_inbox_urls(factories):
a1 = factories["federation.Actor"](
shared_inbox_url=None, inbox_url="https://a1.inbox"
)
a2 = factories["federation.Actor"](
shared_inbox_url="https://shared.inbox", inbox_url="https://a2.inbox"
)
factories["federation.Actor"](
shared_inbox_url="https://shared.inbox", inbox_url="https://a3.inbox"
)
expected = sorted(set([a1.inbox_url, a2.shared_inbox_url]))
assert activity.get_inbox_urls(a1.__class__.objects.all()) == expected
def test_receive_invalid_data(factories): def test_receive_invalid_data(factories):
remote_actor = factories["federation.Actor"]() remote_actor = factories["federation.Actor"]()
a = {"@context": [], "actor": remote_actor.fid, "id": "https://test.activity"} a = {"@context": [], "actor": remote_actor.fid, "id": "https://test.activity"}
@ -212,9 +196,6 @@ def test_outbox_router_dispatch(mocker, factories, now):
"actor": actor, "actor": actor,
} }
expected_deliveries_url = activity.get_inbox_urls(
models.Actor.objects.filter(pk__in=[r1.pk, r2.pk])
)
router.connect({"type": "Noop"}, handler) router.connect({"type": "Noop"}, handler)
activities = router.dispatch({"type": "Noop"}, {"summary": "hello"}) activities = router.dispatch({"type": "Noop"}, {"summary": "hello"})
a = activities[0] a = activities[0]
@ -235,8 +216,8 @@ def test_outbox_router_dispatch(mocker, factories, now):
assert a.uuid is not None assert a.uuid is not None
assert a.deliveries.count() == 2 assert a.deliveries.count() == 2
for url in expected_deliveries_url: for actor in [r1, r2]:
delivery = a.deliveries.get(inbox_url=url) delivery = a.deliveries.get(inbox_url=actor.inbox_url)
assert delivery.is_delivered is False assert delivery.is_delivered is False

View File

@ -0,0 +1,34 @@
def test_domain_14_migration(migrator):
a, f, t = ("federation", "0014_auto_20181205_0958", "0015_populate_domains")
migrator.migrate([(a, f)])
old_apps = migrator.loader.project_state([(a, f)]).apps
Actor = old_apps.get_model(a, "Actor")
a1 = Actor.objects.create(
fid="http://test1.com", preferred_username="test1", old_domain="dOmaiN1.com"
)
a2 = Actor.objects.create(
fid="http://test2.com", preferred_username="test2", old_domain="domain1.com"
)
a3 = Actor.objects.create(
fid="http://test3.com", preferred_username="test2", old_domain="domain2.com"
)
migrator.loader.build_graph()
migrator.migrate([(a, t)])
new_apps = migrator.loader.project_state([(a, t)]).apps
Actor = new_apps.get_model(a, "Actor")
Domain = new_apps.get_model(a, "Domain")
a1 = Actor.objects.get(pk=a1.pk)
a2 = Actor.objects.get(pk=a2.pk)
a3 = Actor.objects.get(pk=a3.pk)
assert Domain.objects.count() == 2
assert a1.domain == Domain.objects.get(pk="domain1.com")
assert a2.domain == Domain.objects.get(pk="domain1.com")
assert a3.domain == Domain.objects.get(pk="domain2.com")
assert Domain.objects.get(pk="domain1.com").creation_date == a1.creation_date
assert Domain.objects.get(pk="domain2.com").creation_date == a3.creation_date

View File

@ -1,6 +1,8 @@
import pytest import pytest
from django import db from django import db
from funkwhale_api.federation import models
def test_cannot_duplicate_actor(factories): def test_cannot_duplicate_actor(factories):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
@ -54,3 +56,44 @@ def test_actor_get_quota(factories):
expected = {"total": 10, "pending": 1, "skipped": 2, "errored": 3, "finished": 4} expected = {"total": 10, "pending": 1, "skipped": 2, "errored": 3, "finished": 4}
assert library.actor.get_current_usage() == expected assert library.actor.get_current_usage() == expected
@pytest.mark.parametrize(
"value, expected",
[
("Domain.com", "domain.com"),
("hello-WORLD.com", "hello-world.com"),
("posés.com", "posés.com"),
],
)
def test_domain_name_saved_properly(value, expected, factories):
domain = factories["federation.Domain"](name=value)
assert domain.name == expected
def test_external_domains(factories, settings):
d1 = factories["federation.Domain"]()
d2 = factories["federation.Domain"]()
settings.FEDERATION_HOSTNAME = d1.pk
assert list(models.Domain.objects.external()) == [d2]
def test_domain_stats(factories):
expected = {
"actors": 0,
"libraries": 0,
"tracks": 0,
"albums": 0,
"uploads": 0,
"artists": 0,
"outbox_activities": 0,
"received_library_follows": 0,
"emitted_library_follows": 0,
"media_total_size": 0,
"media_downloaded_size": 0,
}
domain = factories["federation.Domain"]()
assert domain.get_stats() == expected

View File

@ -43,7 +43,7 @@ def test_actor_serializer_from_ap(db):
assert actor.public_key == payload["publicKey"]["publicKeyPem"] assert actor.public_key == payload["publicKey"]["publicKeyPem"]
assert actor.preferred_username == payload["preferredUsername"] assert actor.preferred_username == payload["preferredUsername"]
assert actor.name == payload["name"] assert actor.name == payload["name"]
assert actor.domain == "test.federation" assert actor.domain.pk == "test.federation"
assert actor.summary == payload["summary"] assert actor.summary == payload["summary"]
assert actor.type == "Person" assert actor.type == "Person"
assert actor.manually_approves_followers == payload["manuallyApprovesFollowers"] assert actor.manually_approves_followers == payload["manuallyApprovesFollowers"]
@ -71,7 +71,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db):
assert actor.followers_url == payload["followers"] assert actor.followers_url == payload["followers"]
assert actor.following_url == payload["following"] assert actor.following_url == payload["following"]
assert actor.preferred_username == payload["preferredUsername"] assert actor.preferred_username == payload["preferredUsername"]
assert actor.domain == "test.federation" assert actor.domain.pk == "test.federation"
assert actor.type == "Person" assert actor.type == "Person"
assert actor.manually_approves_followers is None assert actor.manually_approves_followers is None
@ -110,7 +110,7 @@ def test_actor_serializer_to_ap():
public_key=expected["publicKey"]["publicKeyPem"], public_key=expected["publicKey"]["publicKeyPem"],
preferred_username=expected["preferredUsername"], preferred_username=expected["preferredUsername"],
name=expected["name"], name=expected["name"],
domain="test.federation", domain=models.Domain(pk="test.federation"),
summary=expected["summary"], summary=expected["summary"],
type="Person", type="Person",
manually_approves_followers=False, manually_approves_followers=False,
@ -135,7 +135,7 @@ def test_webfinger_serializer():
actor = models.Actor( actor = models.Actor(
fid=expected["links"][0]["href"], fid=expected["links"][0]["href"],
preferred_username="service", preferred_username="service",
domain="test.federation", domain=models.Domain(pk="test.federation"),
) )
serializer = serializers.ActorWebfingerSerializer(actor) serializer = serializers.ActorWebfingerSerializer(actor)
@ -898,7 +898,7 @@ def test_local_actor_serializer_to_ap(factories):
public_key=expected["publicKey"]["publicKeyPem"], public_key=expected["publicKey"]["publicKeyPem"],
preferred_username=expected["preferredUsername"], preferred_username=expected["preferredUsername"],
name=expected["name"], name=expected["name"],
domain="test.federation", domain=models.Domain.objects.create(pk="test.federation"),
summary=expected["summary"], summary=expected["summary"],
type="Person", type="Person",
manually_approves_followers=False, manually_approves_followers=False,

View File

@ -138,3 +138,55 @@ def test_deliver_to_remote_error(factories, r_mock, now):
assert delivery.is_delivered is False assert delivery.is_delivered is False
assert delivery.attempts == 1 assert delivery.attempts == 1
assert delivery.last_attempt_date == now assert delivery.last_attempt_date == now
def test_fetch_nodeinfo(factories, r_mock, now):
wellknown_url = "https://test.test/.well-known/nodeinfo"
nodeinfo_url = "https://test.test/nodeinfo"
r_mock.get(
wellknown_url,
json={
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": "https://test.test/nodeinfo",
}
]
},
)
r_mock.get(nodeinfo_url, json={"hello": "world"})
assert tasks.fetch_nodeinfo("test.test") == {"hello": "world"}
def test_update_domain_nodeinfo(factories, mocker, now):
domain = factories["federation.Domain"]()
mocker.patch.object(tasks, "fetch_nodeinfo", return_value={"hello": "world"})
assert domain.nodeinfo == {}
assert domain.nodeinfo_fetch_date is None
tasks.update_domain_nodeinfo(domain_name=domain.name)
domain.refresh_from_db()
assert domain.nodeinfo_fetch_date == now
assert domain.nodeinfo == {"status": "ok", "payload": {"hello": "world"}}
def test_update_domain_nodeinfo_error(factories, r_mock, now):
domain = factories["federation.Domain"]()
wellknown_url = "https://{}/.well-known/nodeinfo".format(domain.name)
r_mock.get(wellknown_url, status_code=500)
tasks.update_domain_nodeinfo(domain_name=domain.name)
domain.refresh_from_db()
assert domain.nodeinfo_fetch_date == now
assert domain.nodeinfo == {
"status": "error",
"error": "500 Server Error: None for url: {}".format(wellknown_url),
}

View File

View File

@ -13,8 +13,7 @@ def test_manage_upload_action_delete(factories):
def test_user_update_permission(factories): def test_user_update_permission(factories):
user = factories["users.User"]( user = factories["users.User"](
permission_library=False, permission_library=False,
permission_upload=False, permission_moderation=False,
permission_federation=True,
permission_settings=True, permission_settings=True,
is_active=True, is_active=True,
) )
@ -22,7 +21,7 @@ def test_user_update_permission(factories):
user, user,
data={ data={
"is_active": False, "is_active": False,
"permissions": {"federation": False, "upload": True}, "permissions": {"moderation": True, "settings": False},
"upload_quota": 12, "upload_quota": 12,
}, },
) )
@ -32,7 +31,25 @@ def test_user_update_permission(factories):
assert user.is_active is False assert user.is_active is False
assert user.upload_quota == 12 assert user.upload_quota == 12
assert user.permission_federation is False assert user.permission_moderation is True
assert user.permission_upload is True
assert user.permission_library is False assert user.permission_library is False
assert user.permission_settings is True assert user.permission_settings is False
def test_manage_domain_serializer(factories, now):
domain = factories["federation.Domain"]()
setattr(domain, "actors_count", 42)
setattr(domain, "outbox_activities_count", 23)
setattr(domain, "last_activity_date", now)
expected = {
"name": domain.name,
"creation_date": domain.creation_date.isoformat().split("+")[0] + "Z",
"last_activity_date": now,
"actors_count": 42,
"outbox_activities_count": 23,
"nodeinfo": {},
"nodeinfo_fetch_date": None,
}
s = serializers.ManageDomainSerializer(domain)
assert s.data == expected

View File

@ -1,6 +1,7 @@
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.manage import serializers, views from funkwhale_api.manage import serializers, views
@ -10,6 +11,7 @@ from funkwhale_api.manage import serializers, views
(views.ManageUploadViewSet, ["library"], "and"), (views.ManageUploadViewSet, ["library"], "and"),
(views.ManageUserViewSet, ["settings"], "and"), (views.ManageUserViewSet, ["settings"], "and"),
(views.ManageInvitationViewSet, ["settings"], "and"), (views.ManageInvitationViewSet, ["settings"], "and"),
(views.ManageDomainViewSet, ["moderation"], "and"),
], ],
) )
def test_permissions(assert_user_permission, view, permissions, operator): def test_permissions(assert_user_permission, view, permissions, operator):
@ -64,3 +66,49 @@ def test_invitation_view_create(factories, superuser_api_client, mocker):
assert response.status_code == 201 assert response.status_code == 201
assert superuser_api_client.user.invitations.latest("id") is not None assert superuser_api_client.user.invitations.latest("id") is not None
def test_domain_list(factories, superuser_api_client, settings):
factories["federation.Domain"](pk=settings.FEDERATION_HOSTNAME)
d = factories["federation.Domain"]()
url = reverse("api:v1:manage:federation:domains-list")
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["name"] == d.pk
def test_domain_detail(factories, superuser_api_client):
d = factories["federation.Domain"]()
url = reverse("api:v1:manage:federation:domains-detail", kwargs={"pk": d.name})
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["name"] == d.pk
def test_domain_nodeinfo(factories, superuser_api_client, mocker):
domain = factories["federation.Domain"]()
url = reverse(
"api:v1:manage:federation:domains-nodeinfo", kwargs={"pk": domain.name}
)
mocker.patch.object(
federation_tasks, "fetch_nodeinfo", return_value={"hello": "world"}
)
update_domain_nodeinfo = mocker.spy(federation_tasks, "update_domain_nodeinfo")
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data == {"status": "ok", "payload": {"hello": "world"}}
update_domain_nodeinfo.assert_called_once_with(domain_name=domain.name)
def test_domain_stats(factories, superuser_api_client, mocker):
domain = factories["federation.Domain"]()
mocker.patch.object(domain.__class__, "get_stats", return_value={"hello": "world"})
url = reverse("api:v1:manage:federation:domains-stats", kwargs={"pk": domain.name})
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data == {"hello": "world"}

View File

@ -1,153 +0,0 @@
import pytest
from django.core.exceptions import ValidationError
from funkwhale_api.music.models import Track
from funkwhale_api.radios import filters
@filters.registry.register
class NoopFilter(filters.RadioFilter):
code = "noop"
def get_query(self, candidates, **kwargs):
return
def test_most_simple_radio_does_not_filter_anything(factories):
factories["music.Track"].create_batch(3)
radio = factories["radios.Radio"](config=[{"type": "noop"}])
assert radio.version == 0
assert radio.get_candidates().count() == 3
def test_filter_can_use_custom_queryset(factories):
tracks = factories["music.Track"].create_batch(3)
candidates = Track.objects.filter(pk=tracks[0].pk)
qs = filters.run([{"type": "noop"}], candidates=candidates)
assert qs.count() == 1
assert qs.first() == tracks[0]
def test_filter_on_tag(factories):
tracks = factories["music.Track"].create_batch(3, tags=["metal"])
factories["music.Track"].create_batch(3, tags=["pop"])
expected = tracks
f = [{"type": "tag", "names": ["metal"]}]
candidates = filters.run(f)
assert list(candidates.order_by("pk")) == expected
def test_filter_on_artist(factories):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist1)
factories["music.Track"].create_batch(3, artist=artist2)
expected = list(artist1.tracks.order_by("pk"))
f = [{"type": "artist", "ids": [artist1.pk]}]
candidates = filters.run(f)
assert list(candidates.order_by("pk")) == expected
def test_can_combine_with_or(factories):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
artist3 = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist1)
factories["music.Track"].create_batch(3, artist=artist2)
factories["music.Track"].create_batch(3, artist=artist3)
expected = Track.objects.exclude(artist=artist3).order_by("pk")
f = [
{"type": "artist", "ids": [artist1.pk]},
{"type": "artist", "ids": [artist2.pk], "operator": "or"},
]
candidates = filters.run(f)
assert list(candidates.order_by("pk")) == list(expected)
def test_can_combine_with_and(factories):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
metal_tracks = factories["music.Track"].create_batch(
2, artist=artist1, tags=["metal"]
)
factories["music.Track"].create_batch(2, artist=artist1, tags=["pop"])
factories["music.Track"].create_batch(3, artist=artist2)
expected = metal_tracks
f = [
{"type": "artist", "ids": [artist1.pk]},
{"type": "tag", "names": ["metal"], "operator": "and"},
]
candidates = filters.run(f)
assert list(candidates.order_by("pk")) == list(expected)
def test_can_negate(factories):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist1)
factories["music.Track"].create_batch(3, artist=artist2)
expected = artist2.tracks.order_by("pk")
f = [{"type": "artist", "ids": [artist1.pk], "not": True}]
candidates = filters.run(f)
assert list(candidates.order_by("pk")) == list(expected)
def test_can_group(factories):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
factories["music.Track"].create_batch(2, artist=artist1)
t1 = factories["music.Track"].create_batch(2, artist=artist1, tags=["metal"])
factories["music.Track"].create_batch(2, artist=artist2)
t2 = factories["music.Track"].create_batch(2, artist=artist2, tags=["metal"])
factories["music.Track"].create_batch(2, tags=["metal"])
expected = t1 + t2
f = [
{"type": "tag", "names": ["metal"]},
{
"type": "group",
"operator": "and",
"filters": [
{"type": "artist", "ids": [artist1.pk], "operator": "or"},
{"type": "artist", "ids": [artist2.pk], "operator": "or"},
],
},
]
candidates = filters.run(f)
assert list(candidates.order_by("pk")) == list(expected)
def test_artist_filter_clean_config(factories):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
config = filters.clean_config({"type": "artist", "ids": [artist2.pk, artist1.pk]})
expected = {
"type": "artist",
"ids": [artist1.pk, artist2.pk],
"names": [artist1.name, artist2.name],
}
assert filters.clean_config(config) == expected
def test_can_check_artist_filter(factories):
artist = factories["music.Artist"]()
assert filters.validate({"type": "artist", "ids": [artist.pk]})
with pytest.raises(ValidationError):
filters.validate({"type": "artist", "ids": [artist.pk + 1]})
def test_can_check_operator():
assert filters.validate({"type": "group", "operator": "or", "filters": []})
assert filters.validate({"type": "group", "operator": "and", "filters": []})
with pytest.raises(ValidationError):
assert filters.validate({"type": "group", "operator": "nope", "filters": []})

View File

@ -46,23 +46,22 @@ def test_get_permissions_regular(factories):
def test_get_permissions_default(factories, preferences): def test_get_permissions_default(factories, preferences):
preferences["users__default_permissions"] = ["upload", "federation"] preferences["users__default_permissions"] = ["library", "moderation"]
user = factories["users.User"]() user = factories["users.User"]()
perms = user.get_permissions() perms = user.get_permissions()
assert perms["upload"] is True assert perms["moderation"] is True
assert perms["federation"] is True assert perms["library"] is True
assert perms["library"] is False
assert perms["settings"] is False assert perms["settings"] is False
@pytest.mark.parametrize( @pytest.mark.parametrize(
"args,perms,expected", "args,perms,expected",
[ [
({"is_superuser": True}, ["federation", "library"], True), ({"is_superuser": True}, ["moderation", "library"], True),
({"is_superuser": False}, ["federation"], False), ({"is_superuser": False}, ["moderation"], False),
({"permission_library": True}, ["library"], True), ({"permission_library": True}, ["library"], True),
({"permission_library": True}, ["library", "federation"], False), ({"permission_library": True}, ["library", "moderation"], False),
], ],
) )
def test_has_permissions_and(args, perms, expected, factories): def test_has_permissions_and(args, perms, expected, factories):
@ -73,10 +72,10 @@ def test_has_permissions_and(args, perms, expected, factories):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"args,perms,expected", "args,perms,expected",
[ [
({"is_superuser": True}, ["federation", "library"], True), ({"is_superuser": True}, ["moderation", "library"], True),
({"is_superuser": False}, ["federation"], False), ({"is_superuser": False}, ["moderation"], False),
({"permission_library": True}, ["library", "federation"], True), ({"permission_library": True}, ["library", "moderation"], True),
({"permission_library": True}, ["federation"], False), ({"permission_library": True}, ["moderation"], False),
], ],
) )
def test_has_permissions_or(args, perms, expected, factories): def test_has_permissions_or(args, perms, expected, factories):
@ -137,7 +136,7 @@ def test_creating_actor_from_user(factories, settings):
actor = models.create_actor(user) actor = models.create_actor(user)
assert actor.preferred_username == "Hello_M_world" # slugified assert actor.preferred_username == "Hello_M_world" # slugified
assert actor.domain == settings.FEDERATION_HOSTNAME assert actor.domain.pk == settings.FEDERATION_HOSTNAME
assert actor.type == "Person" assert actor.type == "Person"
assert actor.name == user.username assert actor.name == user.username
assert actor.manually_approves_followers is False assert actor.manually_approves_followers is False

View File

@ -21,21 +21,21 @@ def test_has_user_permission_anonymous(anonymous_user, api_request):
@pytest.mark.parametrize("value", [True, False]) @pytest.mark.parametrize("value", [True, False])
def test_has_user_permission_logged_in_single(value, factories, api_request): def test_has_user_permission_logged_in_single(value, factories, api_request):
user = factories["users.User"](permission_federation=value) user = factories["users.User"](permission_moderation=value)
class View(APIView): class View(APIView):
required_permissions = ["federation"] required_permissions = ["moderation"]
view = View() view = View()
permission = permissions.HasUserPermission() permission = permissions.HasUserPermission()
request = api_request.get("/") request = api_request.get("/")
setattr(request, "user", user) setattr(request, "user", user)
result = permission.has_permission(request, view) result = permission.has_permission(request, view)
assert result == user.has_permissions("federation") == value assert result == user.has_permissions("moderation") == value
@pytest.mark.parametrize( @pytest.mark.parametrize(
"federation,library,expected", "moderation,library,expected",
[ [
(True, False, False), (True, False, False),
(False, True, False), (False, True, False),
@ -44,14 +44,14 @@ def test_has_user_permission_logged_in_single(value, factories, api_request):
], ],
) )
def test_has_user_permission_logged_in_multiple_and( def test_has_user_permission_logged_in_multiple_and(
federation, library, expected, factories, api_request moderation, library, expected, factories, api_request
): ):
user = factories["users.User"]( user = factories["users.User"](
permission_federation=federation, permission_library=library permission_moderation=moderation, permission_library=library
) )
class View(APIView): class View(APIView):
required_permissions = ["federation", "library"] required_permissions = ["moderation", "library"]
permission_operator = "and" permission_operator = "and"
view = View() view = View()
@ -59,11 +59,11 @@ def test_has_user_permission_logged_in_multiple_and(
request = api_request.get("/") request = api_request.get("/")
setattr(request, "user", user) setattr(request, "user", user)
result = permission.has_permission(request, view) result = permission.has_permission(request, view)
assert result == user.has_permissions("federation", "library") == expected assert result == user.has_permissions("moderation", "library") == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
"federation,library,expected", "moderation,library,expected",
[ [
(True, False, True), (True, False, True),
(False, True, True), (False, True, True),
@ -72,14 +72,14 @@ def test_has_user_permission_logged_in_multiple_and(
], ],
) )
def test_has_user_permission_logged_in_multiple_or( def test_has_user_permission_logged_in_multiple_or(
federation, library, expected, factories, api_request moderation, library, expected, factories, api_request
): ):
user = factories["users.User"]( user = factories["users.User"](
permission_federation=federation, permission_library=library permission_moderation=moderation, permission_library=library
) )
class View(APIView): class View(APIView):
required_permissions = ["federation", "library"] required_permissions = ["moderation", "library"]
permission_operator = "or" permission_operator = "or"
view = View() view = View()
@ -87,6 +87,6 @@ def test_has_user_permission_logged_in_multiple_or(
request = api_request.get("/") request = api_request.get("/")
setattr(request, "user", user) setattr(request, "user", user)
result = permission.has_permission(request, view) result = permission.has_permission(request, view)
has_permission_result = user.has_permissions("federation", "library", operator="or") has_permission_result = user.has_permissions("moderation", "library", operator="or")
assert result == has_permission_result == expected assert result == has_permission_result == expected

View File

@ -85,6 +85,8 @@ services:
- redis - redis
networks: networks:
- internal - internal
cap_add:
- SYS_PTRACE
nginx: nginx:
command: /entrypoint.sh command: /entrypoint.sh
env_file: env_file:

View File

@ -76,19 +76,27 @@
class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate>Add content</translate></router-link> class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate>Add content</translate></router-link>
</div> </div>
</div> </div>
<div class="item" v-if="$store.state.auth.availablePermissions['settings']"> <div class="item" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']">
<header class="header"><translate>Administration</translate></header> <header class="header"><translate>Administration</translate></header>
<div class="menu"> <div class="menu">
<router-link <router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item" class="item"
:to="{path: '/manage/settings'}"> :to="{path: '/manage/settings'}">
<i class="settings icon"></i><translate>Settings</translate> <i class="settings icon"></i><translate>Settings</translate>
</router-link> </router-link>
<router-link <router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item" class="item"
:to="{name: 'manage.users.users.list'}"> :to="{name: 'manage.users.users.list'}">
<i class="users icon"></i><translate>Users</translate> <i class="users icon"></i><translate>Users</translate>
</router-link> </router-link>
<router-link
v-if="$store.state.auth.availablePermissions['moderation']"
class="item"
:to="{name: 'manage.moderation.domains.list'}">
<i class="shield icon"></i><translate>Moderation</translate>
</router-link>
</div> </div>
</div> </div>
</nav> </nav>

View File

@ -1,7 +1,7 @@
<template> <template>
<table class="ui compact very basic single line unstackable table"> <table class="ui compact very basic single line unstackable table">
<thead> <thead>
<tr v-if="actions.length > 0"> <tr v-if="actionUrl && actions.length > 0">
<th colspan="1000"> <th colspan="1000">
<div class="ui small form"> <div class="ui small form">
<div class="ui inline fields"> <div class="ui inline fields">
@ -130,8 +130,8 @@ import axios from 'axios'
export default { export default {
props: { props: {
actionUrl: {type: String, required: true}, actionUrl: {type: String, required: false, default: null},
idField: {type: String, required: true, default: 'id'}, idField: {type: String, required: false, default: 'id'},
objectsData: {type: Object, required: true}, objectsData: {type: Object, required: true},
actions: {type: Array, required: true, default: () => { return [] }}, actions: {type: Array, required: true, default: () => { return [] }},
filters: {type: Object, required: false, default: () => { return {} }}, filters: {type: Object, required: false, default: () => { return {} }},

View File

@ -0,0 +1,33 @@
<template>
<button @click="ajaxCall" :class="['ui', {loading: isLoading}, 'button']">
<slot></slot>
</button>
</template>
<script>
import axios from 'axios'
export default {
props: {
url: {type: String, required: true},
method: {type: String, required: true},
},
data () {
return {
isLoading: false,
}
},
methods: {
ajaxCall () {
var self = this
this.isLoading = true
axios[this.method](this.url).then(response => {
self.$emit('action-done', response.data)
self.isLoading = false
}, error => {
self.isLoading = false
self.$emit('action-error', error)
})
}
}
}
</script>

View File

@ -36,4 +36,9 @@ import CopyInput from '@/components/common/CopyInput'
Vue.component('copy-input', CopyInput) Vue.component('copy-input', CopyInput)
import AjaxButton from '@/components/common/AjaxButton'
Vue.component('ajax-button', AjaxButton)
export default {} export default {}

View File

@ -0,0 +1,190 @@
<template>
<div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label><translate>Search</translate></label>
<input type="text" v-model="search" :placeholder="labels.searchPlaceholder" />
</div>
<div class="field">
<label><translate>Ordering</translate></label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label><translate>Ordering direction</translate></label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate>Ascending</translate></option>
<option value="-"><translate>Descending</translate></option>
</select>
</div>
</div>
</div>
<div class="dimmable">
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<action-table
v-if="result"
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
:filters="actionFilters">
<template slot="header-cells">
<th><translate>Name</translate></th>
<th><translate>Users</translate></th>
<th><translate>Received messages</translate></th>
<th><translate>First seen</translate></th>
<th><translate>Last activity</translate></th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.name }}">{{ scope.obj.name }}</router-link>
</td>
<td>
{{ scope.obj.actors_count }}
</td>
<td>
{{ scope.obj.outbox_activities_count }}
</td>
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td>
<human-date v-if="scope.obj.last_activity_date" :date="scope.obj.last_activity_date"></human-date>
<translate v-else>N/A</translate>
</td>
</template>
</action-table>
</div>
<div>
<pagination
v-if="result && result.count > paginateBy"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
<span v-if="result && result.results.length > 0">
<translate
:translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}">
Showing results %{ start }-%{ end } on %{ total }
</translate>
</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from '@/lodash'
import time from '@/utils/time'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
import OrderingMixin from '@/components/mixins/Ordering'
import TranslationsMixin from '@/components/mixins/Translations'
export default {
mixins: [OrderingMixin, TranslationsMixin],
props: {
filters: {type: Object, required: false}
},
components: {
Pagination,
ActionTable
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
time,
isLoading: false,
result: null,
page: 1,
paginateBy: 50,
search: '',
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['name', 'name'],
['creation_date', 'first_seen'],
['last_activity_date', 'last_activity'],
['actors_count', 'users'],
['outbox_activities_count', 'received_messages']
]
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
self.checked = []
axios.get('/manage/federation/domains/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
}
},
computed: {
labels () {
return {
searchPlaceholder: this.$gettext('Search by name...')
}
},
actionFilters () {
var currentFilters = {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
} else {
return currentFilters
}
},
actions () {
return [
// {
// name: 'delete',
// label: this.$gettext('Delete'),
// isDangerous: true
// }
]
}
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.fetchData()
},
orderingDirection () {
this.fetchData()
}
}
}
</script>

View File

@ -15,6 +15,7 @@ export default {
}, },
filters: { filters: {
creation_date: this.$gettext('Creation date'), creation_date: this.$gettext('Creation date'),
first_seen: this.$gettext('First seen date'),
accessed_date: this.$gettext('Accessed date'), accessed_date: this.$gettext('Accessed date'),
modification_date: this.$gettext('Modification date'), modification_date: this.$gettext('Modification date'),
imported_date: this.$gettext('Imported date'), imported_date: this.$gettext('Imported date'),
@ -30,6 +31,8 @@ export default {
date_joined: this.$gettext('Sign-up date'), date_joined: this.$gettext('Sign-up date'),
last_activity: this.$gettext('Last activity'), last_activity: this.$gettext('Last activity'),
username: this.$gettext('Username'), username: this.$gettext('Username'),
users: this.$gettext('Users'),
received_messages: this.$gettext('Received messages'),
} }
} }
} }

View File

@ -30,6 +30,9 @@ import AdminUsersBase from '@/views/admin/users/Base'
import AdminUsersDetail from '@/views/admin/users/UsersDetail' import AdminUsersDetail from '@/views/admin/users/UsersDetail'
import AdminUsersList from '@/views/admin/users/UsersList' import AdminUsersList from '@/views/admin/users/UsersList'
import AdminInvitationsList from '@/views/admin/users/InvitationsList' import AdminInvitationsList from '@/views/admin/users/InvitationsList'
import AdminModerationBase from '@/views/admin/moderation/Base'
import AdminDomainsList from '@/views/admin/moderation/DomainsList'
import AdminDomainsDetail from '@/views/admin/moderation/DomainsDetail'
import ContentBase from '@/views/content/Base' import ContentBase from '@/views/content/Base'
import ContentHome from '@/views/content/Home' import ContentHome from '@/views/content/Home'
import LibrariesHome from '@/views/content/libraries/Home' import LibrariesHome from '@/views/content/libraries/Home'
@ -224,6 +227,23 @@ export default new Router({
} }
] ]
}, },
{
path: '/manage/moderation',
component: AdminModerationBase,
children: [
{
path: 'domains',
name: 'manage.moderation.domains.list',
component: AdminDomainsList
},
{
path: 'domains/:id',
name: 'manage.moderation.domains.detail',
component: AdminDomainsDetail,
props: true
}
]
},
{ {
path: '/library', path: '/library',
component: Library, component: Library,

View File

@ -9,10 +9,9 @@ export default {
authenticated: false, authenticated: false,
username: '', username: '',
availablePermissions: { availablePermissions: {
federation: false,
settings: false, settings: false,
library: false, library: false,
upload: false moderation: false
}, },
profile: null, profile: null,
token: '', token: '',

View File

@ -27,7 +27,7 @@
@import "~semantic-ui-css/components/label.css"; @import "~semantic-ui-css/components/label.css";
@import "~semantic-ui-css/components/list.css"; @import "~semantic-ui-css/components/list.css";
@import "~semantic-ui-css/components/loader.css"; @import "~semantic-ui-css/components/loader.css";
// @import "~semantic-ui-css/components/placeholder.css"; @import "~semantic-ui-css/components/placeholder.css";
// @import "~semantic-ui-css/components/rail.css"; // @import "~semantic-ui-css/components/rail.css";
// @import "~semantic-ui-css/components/reveal.css"; // @import "~semantic-ui-css/components/reveal.css";
@import "~semantic-ui-css/components/segment.css"; @import "~semantic-ui-css/components/segment.css";
@ -251,3 +251,11 @@ button.reset {
.right.floated { .right.floated {
float: right; float: right;
} }
[data-tooltip]::after {
white-space: normal;
width: 300px;
max-width: 300px;
z-index: 999;
}

View File

@ -0,0 +1,23 @@
<template>
<div class="main pusher" v-title="labels.manageDomains">
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
<router-link
class="ui item"
:to="{name: 'manage.moderation.domains.list'}"><translate>Domains</translate></router-link>
</nav>
<router-view :key="$route.fullPath"></router-view>
</div>
</template>
<script>
export default {
computed: {
labels() {
return {
manageDomains: this.$gettext("Manage domains"),
secondaryMenu: this.$gettext("Secondary menu")
}
}
}
}
</script>

View File

@ -0,0 +1,298 @@
<template>
<main>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted cloud icon"></i>
<div class="content">
{{ object.name }}
<div class="sub header">
<a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
<translate>Open website</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
</div>
</h2>
</div>
</section>
<div class="ui vertical stripe segment">
<div class="ui stackable three column grid">
<div class="column">
<section>
<h3 class="ui header">
<i class="info icon"></i>
<div class="content">
<translate>Instance data</translate>
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<translate>First seen</translate>
</td>
<td>
<human-date :date="object.creation_date"></human-date>
</td>
</tr>
<tr>
<td>
<translate>Last checked</translate>
</td>
<td>
<human-date v-if="object.nodeinfo_fetch_date" :date="object.nodeinfo_fetch_date"></human-date>
<translate v-else>N/A</translate>
</td>
</tr>
<template v-if="object.nodeinfo && object.nodeinfo.status === 'ok'">
<tr>
<td>
<translate>Software</translate>
</td>
<td>
{{ lodash.get(object, 'nodeinfo.payload.software.name', $gettext('N/A')) }} ({{ lodash.get(object, 'nodeinfo.payload.software.version', $gettext('N/A')) }})
</td>
</tr>
<tr>
<td>
<translate>Name</translate>
</td>
<td>
{{ lodash.get(object, 'nodeinfo.payload.metadata.nodeName', $gettext('N/A')) }}
</td>
</tr>
<tr>
<td>
<translate>Total users</translate>
</td>
<td>
{{ lodash.get(object, 'nodeinfo.payload.usage.users.total', $gettext('N/A')) }}
</td>
</tr>
</template>
<template v-if="object.nodeinfo && object.nodeinfo.status === 'error'">
<tr>
<td>
<translate>Status</translate>
</td>
<td>
<translate>Error while fetching node info</translate>&nbsp;
<span :data-tooltip="object.nodeinfo.error"><i class="question circle icon"></i></span>
</td>
</tr>
</template>
</tbody>
</table>
<ajax-button @action-done="refreshNodeInfo" method="get" :url="'manage/federation/domains/' + object.name + '/nodeinfo/'">
<translate>Refresh node info</translate>
</ajax-button>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon"></i>
<div class="content">
<translate>Activity</translate>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr>
<td>
<translate>Known users</translate>
</td>
<td>
{{ stats.actors }}
</td>
</tr>
<tr>
<td>
<translate>Emitted messages</translate>
</td>
<td>
{{ stats.outbox_activities}}
</td>
</tr>
<tr>
<td>
<translate>Received library follows</translate>
</td>
<td>
{{ stats.received_library_follows}}
</td>
</tr>
<tr>
<td>
<translate>Emitted library follows</translate>
</td>
<td>
{{ stats.emitted_library_follows}}
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="music icon"></i>
<div class="content">
<translate>Audio content</translate>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr>
<td>
<translate>Artists</translate>
</td>
<td>
{{ stats.artists }}
</td>
</tr>
<tr>
<td>
<translate>Albums</translate>
</td>
<td>
{{ stats.albums}}
</td>
</tr>
<tr>
<td>
<translate>Tracks</translate>
</td>
<td>
{{ stats.tracks }}
</td>
</tr>
<tr>
<td>
<translate>Libraries</translate>
</td>
<td>
{{ stats.libraries }}
</td>
</tr>
<tr>
<td>
<translate>Uploads</translate>
</td>
<td>
{{ stats.uploads }}
</td>
</tr>
<tr>
<td>
<translate>Cached size</translate>
</td>
<td>
{{ stats.media_downloaded_size | humanSize }}
</td>
</tr>
<tr>
<td>
<translate>Total size</translate>
</td>
<td>
{{ stats.media_total_size | humanSize }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</template>
<script>
import axios from "axios"
import logger from "@/logging"
import lodash from '@/lodash'
export default {
props: ["id"],
data() {
return {
lodash,
isLoading: true,
isLoadingStats: false,
object: null,
stats: null,
permissions: [],
}
},
created() {
this.fetchData()
this.fetchStats()
},
methods: {
fetchData() {
var self = this
this.isLoading = true
let url = "manage/federation/domains/" + this.id + "/"
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
})
},
fetchStats() {
var self = this
this.isLoadingStats = true
let url = "manage/federation/domains/" + this.id + "/stats/"
axios.get(url).then(response => {
self.stats = response.data
self.isLoadingStats = false
})
},
refreshNodeInfo (data) {
this.object.nodeinfo = data
this.object.nodeinfo_fetch_date = new Date()
},
},
computed: {
labels() {
return {
statsWarning: this.$gettext("Statistics are computed from known activity and content on your instance, and do not reflect general activity for this domain")
}
},
externalUrl () {
return `https://${this.object.name}`
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,30 @@
<template>
<main v-title="labels.domains">
<section class="ui vertical stripe segment">
<h2 class="ui header"><translate>Domains</translate></h2>
<div class="ui hidden divider"></div>
<domains-table></domains-table>
</section>
</main>
</template>
<script>
import DomainsTable from "@/components/manage/moderation/DomainsTable"
export default {
components: {
DomainsTable
},
computed: {
labels() {
return {
domains: this.$gettext("Domains")
}
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>