Skip to content
Snippets Groups Projects
Verified Commit e4117043 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Added API endpoint and UI to list known domains

parent 34ec869c
Branches master
No related tags found
No related merge requests found
Showing
with 402 additions and 161 deletions
......@@ -62,9 +62,32 @@ class ActorQuerySet(models.QuerySet):
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)
objects = DomainQuerySet.as_manager()
def __str__(self):
return self.name
......
from django_filters import rest_framework as filters
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.users import models as users_models
......@@ -20,6 +21,14 @@ class ManageUploadFilterSet(filters.FilterSet):
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):
q = fields.SearchFilter(search_fields=["username", "email", "name"])
......
......@@ -3,6 +3,7 @@ from django.db import transaction
from rest_framework import 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.users import models as users_models
......@@ -168,3 +169,28 @@ class ManageInvitationActionSerializer(common_serializers.ActionSerializer):
@transaction.atomic
def handle_delete(self, objects):
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",
]
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)
......@@ -3,6 +3,8 @@ from rest_framework import routers
from . import views
federation_router = routers.SimpleRouter()
federation_router.register(r"domains", views.ManageDomainViewSet, "domains")
library_router = routers.SimpleRouter()
library_router.register(r"uploads", views.ManageUploadViewSet, "uploads")
users_router = routers.SimpleRouter()
......@@ -10,6 +12,10 @@ users_router.register(r"users", views.ManageUserViewSet, "users")
users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
urlpatterns = [
url(
r"^federation/",
include((federation_router.urls, "federation"), namespace="federation"),
),
url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
]
......@@ -2,6 +2,7 @@ from rest_framework import mixins, response, viewsets
from rest_framework.decorators import list_route
from funkwhale_api.common import preferences
from funkwhale_api.federation import models as federation_models
from funkwhale_api.music import models as music_models
from funkwhale_api.users import models as users_models
from funkwhale_api.users.permissions import HasUserPermission
......@@ -92,3 +93,26 @@ class ManageInvitationViewSet(
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageDomainViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
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",
]
import pytest
from django import db
from funkwhale_api.federation import models
def test_cannot_duplicate_actor(factories):
actor = factories["federation.Actor"]()
......@@ -67,3 +69,11 @@ def test_actor_get_quota(factories):
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]
......@@ -21,7 +21,7 @@ def test_user_update_permission(factories):
user,
data={
"is_active": False,
"permissions": {"federation": False, "upload": True},
"permissions": {"moderation": True, "settings": False},
"upload_quota": 12,
},
)
......@@ -33,4 +33,21 @@ def test_user_update_permission(factories):
assert user.upload_quota == 12
assert user.permission_moderation is True
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,
}
s = serializers.ManageDomainSerializer(domain)
assert s.data == expected
......@@ -10,6 +10,7 @@ from funkwhale_api.manage import serializers, views
(views.ManageUploadViewSet, ["library"], "and"),
(views.ManageUserViewSet, ["settings"], "and"),
(views.ManageInvitationViewSet, ["settings"], "and"),
(views.ManageDomainViewSet, ["moderation"], "and"),
],
)
def test_permissions(assert_user_permission, view, permissions, operator):
......@@ -64,3 +65,15 @@ def test_invitation_view_create(factories, superuser_api_client, mocker):
assert response.status_code == 201
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
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": []})
......@@ -76,19 +76,27 @@
class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate>Add content</translate></router-link>
</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>
<div class="menu">
<router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item"
:to="{path: '/manage/settings'}">
<i class="settings icon"></i><translate>Settings</translate>
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item"
:to="{name: 'manage.users.users.list'}">
<i class="users icon"></i><translate>Users</translate>
</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>
</nav>
......
<template>
<table class="ui compact very basic single line unstackable table">
<thead>
<tr v-if="actions.length > 0">
<tr v-if="actionUrl && actions.length > 0">
<th colspan="1000">
<div class="ui small form">
<div class="ui inline fields">
......@@ -130,8 +130,8 @@ import axios from 'axios'
export default {
props: {
actionUrl: {type: String, required: true},
idField: {type: String, required: true, default: 'id'},
actionUrl: {type: String, required: false, default: null},
idField: {type: String, required: false, default: 'id'},
objectsData: {type: Object, required: true},
actions: {type: Array, required: true, default: () => { return [] }},
filters: {type: Object, required: false, default: () => { return {} }},
......
<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>First seen</translate></th>
<th><translate>Users</translate></th>
<th><translate>Last activity</translate></th>
<th><translate>Received messages</translate></th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<router-link :to="{name: 'manage.moderation.domain.detail', params: {id: scope.obj.name }}">{{ scope.obj.name }}</router-link>
</td>
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td>
{{ scope.obj.actors_count }}
</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>
<td>
{{ scope.obj.outbox_activities_count }}
</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>
......@@ -15,6 +15,7 @@ export default {
},
filters: {
creation_date: this.$gettext('Creation date'),
first_seen: this.$gettext('First seen date'),
accessed_date: this.$gettext('Accessed date'),
modification_date: this.$gettext('Modification date'),
imported_date: this.$gettext('Imported date'),
......@@ -30,6 +31,8 @@ export default {
date_joined: this.$gettext('Sign-up date'),
last_activity: this.$gettext('Last activity'),
username: this.$gettext('Username'),
users: this.$gettext('Users'),
received_messages: this.$gettext('Received messages'),
}
}
}
......
......@@ -30,6 +30,8 @@ import AdminUsersBase from '@/views/admin/users/Base'
import AdminUsersDetail from '@/views/admin/users/UsersDetail'
import AdminUsersList from '@/views/admin/users/UsersList'
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
import AdminModerationBase from '@/views/admin/moderation/Base'
import AdminDomainsList from '@/views/admin/moderation/DomainsList'
import ContentBase from '@/views/content/Base'
import ContentHome from '@/views/content/Home'
import LibrariesHome from '@/views/content/libraries/Home'
......@@ -224,6 +226,17 @@ export default new Router({
}
]
},
{
path: '/manage/moderation',
component: AdminModerationBase,
children: [
{
path: 'domains',
name: 'manage.moderation.domains.list',
component: AdminDomainsList
}
]
},
{
path: '/library',
component: Library,
......
......@@ -9,10 +9,9 @@ export default {
authenticated: false,
username: '',
availablePermissions: {
federation: false,
settings: false,
library: false,
upload: false
moderation: false
},
profile: null,
token: '',
......
<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>
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment