From e186c6bb06508714812575da94b18fef78b9a0f5 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Thu, 3 Jan 2019 17:10:02 +0100
Subject: [PATCH] Admin UI to list and manage remote and local accounts
---
api/funkwhale_api/federation/models.py | 47 +-
api/funkwhale_api/manage/filters.py | 7 +-
api/funkwhale_api/manage/serializers.py | 14 +-
api/funkwhale_api/manage/views.py | 4 +-
api/funkwhale_api/users/models.py | 3 +
api/tests/federation/test_models.py | 19 +
api/tests/manage/test_serializers.py | 6 +-
front/src/Embed.vue | 2 -
.../manage/moderation/AccountsTable.vue | 205 +++++++++
.../components/manage/users/UsersTable.vue | 10 +-
front/src/components/mixins/SmartSearch.vue | 12 +-
front/src/components/mixins/Translations.vue | 4 +
front/src/router/index.js | 26 +-
.../views/admin/moderation/AccountsDetail.vue | 426 ++++++++++++++++++
.../views/admin/moderation/AccountsList.vue | 33 ++
front/src/views/admin/moderation/Base.vue | 8 +-
.../views/admin/moderation/DomainsDetail.vue | 34 +-
front/src/views/admin/users/UsersDetail.vue | 216 ---------
18 files changed, 795 insertions(+), 281 deletions(-)
create mode 100644 front/src/components/manage/moderation/AccountsTable.vue
create mode 100644 front/src/views/admin/moderation/AccountsDetail.vue
create mode 100644 front/src/views/admin/moderation/AccountsList.vue
delete mode 100644 front/src/views/admin/users/UsersDetail.vue
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index ad88422e4f..35b15b6678 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -61,16 +61,6 @@ class ActorQuerySet(models.QuerySet):
return qs
- def with_outbox_activities_count(self):
- return self.annotate(
- outbox_activities_count=models.Count("outbox_activities", distinct=True)
- )
-
- def with_followers_count(self):
- return self.annotate(
- followers_count=models.Count("received_follows", distinct=True)
- )
-
def with_uploads_count(self):
return self.annotate(
uploads_count=models.Count("libraries__uploads", distinct=True)
@@ -86,7 +76,9 @@ class DomainQuerySet(models.QuerySet):
def with_outbox_activities_count(self):
return self.annotate(
- outbox_activities_count=models.Count("actors__outbox_activities", distinct=True)
+ outbox_activities_count=models.Count(
+ "actors__outbox_activities", distinct=True
+ )
)
@@ -186,10 +178,10 @@ class Actor(models.Model):
@property
def full_username(self):
- return "{}@{}".format(self.preferred_username, self.domain)
+ return "{}@{}".format(self.preferred_username, self.domain_id)
def __str__(self):
- return "{}@{}".format(self.preferred_username, self.domain)
+ return "{}@{}".format(self.preferred_username, self.domain_id)
@property
def is_local(self):
@@ -217,6 +209,35 @@ class Actor(models.Model):
data["total"] = sum(data.values())
return data
+ def get_stats(self):
+ from funkwhale_api.music import models as music_models
+
+ data = Actor.objects.filter(pk=self.pk).aggregate(
+ outbox_activities=models.Count("outbox_activities", distinct=True),
+ libraries=models.Count("libraries", distinct=True),
+ received_library_follows=models.Count(
+ "libraries__received_follows", distinct=True
+ ),
+ emitted_library_follows=models.Count("library_follows", distinct=True),
+ )
+ data["artists"] = music_models.Artist.objects.filter(
+ from_activity__actor=self.pk
+ ).count()
+ data["albums"] = music_models.Album.objects.filter(
+ from_activity__actor=self.pk
+ ).count()
+ data["tracks"] = music_models.Track.objects.filter(
+ from_activity__actor=self.pk
+ ).count()
+
+ uploads = music_models.Upload.objects.filter(library__actor=self.pk)
+ data["uploads"] = uploads.count()
+ 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 InboxItem(models.Model):
"""
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index dfb901924f..51648298a1 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -37,10 +37,15 @@ class ManageActorFilterSet(filters.FilterSet):
search_fields={
"name": {"to": "name"},
"username": {"to": "preferred_username"},
+ "email": {"to": "user__email"},
"bio": {"to": "summary"},
"type": {"to": "type"},
},
- filter_fields={"domain": {"to": "domain_id__iexact"}},
+ filter_fields={
+ "domain": {"to": "domain__name__iexact"},
+ "username": {"to": "preferred_username__iexact"},
+ "email": {"to": "user__email__iexact"},
+ },
)
)
local = filters.BooleanFilter(name="_", method="filter_local")
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 3b06fb8485..76d0cf05fc 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -116,6 +116,7 @@ class ManageUserSerializer(serializers.ModelSerializer):
"permissions",
"privacy_level",
"upload_quota",
+ "full_username",
)
read_only_fields = [
"id",
@@ -194,9 +195,8 @@ class ManageDomainSerializer(serializers.ModelSerializer):
class ManageActorSerializer(serializers.ModelSerializer):
- outbox_activities_count = serializers.SerializerMethodField()
uploads_count = serializers.SerializerMethodField()
- followers_count = serializers.SerializerMethodField()
+ user = ManageUserSerializer()
class Meta:
model = federation_models.Actor
@@ -205,6 +205,7 @@ class ManageActorSerializer(serializers.ModelSerializer):
"url",
"fid",
"preferred_username",
+ "full_username",
"domain",
"name",
"summary",
@@ -215,16 +216,9 @@ class ManageActorSerializer(serializers.ModelSerializer):
"outbox_url",
"shared_inbox_url",
"manually_approves_followers",
- "outbox_activities_count",
"uploads_count",
- "followers_count",
+ "user",
]
def get_uploads_count(self, o):
return getattr(o, "uploads_count", 0)
-
- def get_followers_count(self, o):
- return getattr(o, "followers_count", 0)
-
- def get_outbox_activities_count(self, o):
- return getattr(o, "outbox_activities_count", 0)
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index ddd4fe5714..0697c6c14e 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -138,10 +138,9 @@ class ManageActorViewSet(
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
queryset = (
federation_models.Actor.objects.all()
- .with_outbox_activities_count()
- .with_followers_count()
.with_uploads_count()
.order_by("-creation_date")
+ .select_related("user")
)
serializer_class = serializers.ManageActorSerializer
filter_class = filters.ManageActorFilterSet
@@ -155,7 +154,6 @@ class ManageActorViewSet(
"creation_date",
"last_fetch_date",
"uploads_count",
- "followers_count",
"outbox_activities_count",
]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 07bb4bae4c..79650301e9 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -204,6 +204,9 @@ class User(AbstractUser):
return ["user.{}.{}".format(self.pk, g) for g in groups]
+ def full_username(self):
+ return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
+
def generate_code(length=10):
return "".join(
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index 2936750484..f59293b678 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -97,3 +97,22 @@ def test_domain_stats(factories):
domain = factories["federation.Domain"]()
assert domain.get_stats() == expected
+
+
+def test_actor_stats(factories):
+ expected = {
+ "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,
+ }
+
+ actor = factories["federation.Actor"]()
+
+ assert actor.get_stats() == expected
diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py
index 83d49cd66a..803820b489 100644
--- a/api/tests/manage/test_serializers.py
+++ b/api/tests/manage/test_serializers.py
@@ -55,16 +55,12 @@ def test_manage_domain_serializer(factories, now):
def test_manage_actor_serializer(factories, now):
actor = factories["federation.Actor"]()
- setattr(actor, "outbox_activities_count", 23)
- setattr(actor, "followers_count", 42)
setattr(actor, "uploads_count", 66)
expected = {
"id": actor.id,
"name": actor.name,
"creation_date": actor.creation_date.isoformat().split("+")[0] + "Z",
"last_fetch_date": actor.last_fetch_date.isoformat().split("+")[0] + "Z",
- "outbox_activities_count": 23,
- "followers_count": 42,
"uploads_count": 66,
"fid": actor.fid,
"url": actor.url,
@@ -76,6 +72,8 @@ def test_manage_actor_serializer(factories, now):
"summary": actor.summary,
"preferred_username": actor.preferred_username,
"manually_approves_followers": actor.manually_approves_followers,
+ "full_username": actor.full_username,
+ "user": None,
}
s = serializers.ManageActorSerializer(actor)
diff --git a/front/src/Embed.vue b/front/src/Embed.vue
index fdd3406faa..7987b054a4 100644
--- a/front/src/Embed.vue
+++ b/front/src/Embed.vue
@@ -247,7 +247,6 @@ export default {
self.isLoading = false;
}).catch(error => {
if (error.response) {
- console.log(error.response)
if (error.response.status === 404) {
self.error = 'server_not_found'
}
@@ -274,7 +273,6 @@ export default {
self.isLoading = false;
}).catch(error => {
if (error.response) {
- console.log(error.response)
if (error.response.status === 404) {
self.error = 'server_not_found'
}
diff --git a/front/src/components/manage/moderation/AccountsTable.vue b/front/src/components/manage/moderation/AccountsTable.vue
new file mode 100644
index 0000000000..8750b4ec97
--- /dev/null
+++ b/front/src/components/manage/moderation/AccountsTable.vue
@@ -0,0 +1,205 @@
+<template>
+ <div>
+ <div class="ui inline form">
+ <div class="fields">
+ <div class="ui six wide field">
+ <label><translate>Search</translate></label>
+ <form @submit.prevent="search.query = $refs.search.value">
+ <input ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
+ </form>
+ </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>Domain</translate></th>
+ <th><translate>Uploads</translate></th>
+ <th><translate>First seen</translate></th>
+ <th><translate>Last seen</translate></th>
+ </template>
+ <template slot="row-cells" slot-scope="scope">
+ <td>
+ <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.full_username }}">{{ scope.obj.preferred_username }}</router-link>
+ </td>
+ <td>
+ <template v-if="!scope.obj.user">
+ <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.domain }}">
+ <i class="wrench icon"></i>
+ </router-link>
+ <span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.domain)" :title="scope.obj.domain">{{ scope.obj.domain }}</span>
+ </template>
+ <span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.domain)">
+ <i class="home icon"></i>
+ <translate>Local account</translate>
+ </span>
+ </td>
+ <td>
+ {{ scope.obj.uploads_count }}
+ </td>
+ <td>
+ <human-date :date="scope.obj.creation_date"></human-date>
+ </td>
+ <td>
+ <human-date v-if="scope.obj.last_fetch_date" :date="scope.obj.last_fetch_date"></human-date>
+ </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 {normalizeQuery, parseTokens} from '@/search'
+import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
+import OrderingMixin from '@/components/mixins/Ordering'
+import TranslationsMixin from '@/components/mixins/Translations'
+import SmartSearchMixin from '@/components/mixins/SmartSearch'
+
+
+export default {
+ mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
+ 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: {
+ query: this.defaultQuery,
+ tokens: parseTokens(normalizeQuery(this.defaultQuery))
+ },
+ orderingDirection: defaultOrdering.direction || '+',
+ ordering: defaultOrdering.field,
+ orderingOptions: [
+ ['creation_date', 'first_seen'],
+ ["last_fetch_date", "last_seen"],
+ ["preferred_username", "username"],
+ ["domain", "domain"],
+ ["uploads_count", "uploads"],
+ ]
+ }
+ },
+ created () {
+ this.fetchData()
+ },
+ methods: {
+ fetchData () {
+ let params = _.merge({
+ 'page': this.page,
+ 'page_size': this.paginateBy,
+ 'q': this.search.query,
+ 'ordering': this.getOrderingAsString()
+ }, this.filters)
+ let self = this
+ self.isLoading = true
+ self.checked = []
+ axios.get('/manage/accounts/', {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 domain, username, bio...')
+ }
+ },
+ actionFilters () {
+ var currentFilters = {
+ q: this.search.query
+ }
+ 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>
diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue
index 33b2433cb5..974ca392d9 100644
--- a/front/src/components/manage/users/UsersTable.vue
+++ b/front/src/components/manage/users/UsersTable.vue
@@ -45,7 +45,7 @@
</template>
<template slot="row-cells" slot-scope="scope">
<td>
- <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
+ <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.full_username }}">{{ scope.obj.username }}</router-link>
</td>
<td>
<span>{{ scope.obj.email }}</span>
@@ -168,17 +168,13 @@ export default {
},
permissions () {
return [
- {
- 'code': 'upload',
- 'label': this.$gettext('Upload')
- },
{
'code': 'library',
'label': this.$gettext('Library')
},
{
- 'code': 'federation',
- 'label': this.$gettext('Federation')
+ 'code': 'moderation',
+ 'label': this.$gettext('Moderation')
},
{
'code': 'settings',
diff --git a/front/src/components/mixins/SmartSearch.vue b/front/src/components/mixins/SmartSearch.vue
index 170436b7a1..8b03becbbd 100644
--- a/front/src/components/mixins/SmartSearch.vue
+++ b/front/src/components/mixins/SmartSearch.vue
@@ -4,7 +4,8 @@ import {normalizeQuery, parseTokens, compileTokens} from '@/search'
export default {
props: {
- defaultQuery: {type: String, default: '', required: false},
+ defaultQuery: {type: String, required: false},
+ updateUrl: {type: Boolean, required: false, default: false},
},
methods: {
getTokenValue (key, fallback) {
@@ -47,6 +48,15 @@ export default {
this.search.query = compileTokens(newValue)
this.page = 1
this.fetchData()
+ if (this.updateUrl) {
+ let params = {}
+ if (this.search.query) {
+ params.q = this.search.query
+ }
+ this.$router.replace({
+ query: params
+ })
+ }
},
deep: true
},
diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue
index c982c9ad7f..9d237c9161 100644
--- a/front/src/components/mixins/Translations.vue
+++ b/front/src/components/mixins/Translations.vue
@@ -16,6 +16,7 @@ export default {
filters: {
creation_date: this.$gettext('Creation date'),
first_seen: this.$gettext('First seen date'),
+ last_seen: this.$gettext('Last seen date'),
accessed_date: this.$gettext('Accessed date'),
modification_date: this.$gettext('Modification date'),
imported_date: this.$gettext('Imported date'),
@@ -31,8 +32,11 @@ export default {
date_joined: this.$gettext('Sign-up date'),
last_activity: this.$gettext('Last activity'),
username: this.$gettext('Username'),
+ domain: this.$gettext('Domain'),
users: this.$gettext('Users'),
received_messages: this.$gettext('Received messages'),
+ uploads: this.$gettext('Uploads'),
+ followers: this.$gettext('Followers'),
}
}
}
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 9d4b46917a..492cbd6171 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -27,12 +27,13 @@ import AdminSettings from '@/views/admin/Settings'
import AdminLibraryBase from '@/views/admin/library/Base'
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
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 AdminDomainsDetail from '@/views/admin/moderation/DomainsDetail'
+import AdminAccountsList from '@/views/admin/moderation/AccountsList'
+import AdminAccountsDetail from '@/views/admin/moderation/AccountsDetail'
import ContentBase from '@/views/content/Base'
import ContentHome from '@/views/content/Home'
import LibrariesHome from '@/views/content/libraries/Home'
@@ -214,12 +215,6 @@ export default new Router({
name: 'manage.users.users.list',
component: AdminUsersList
},
- {
- path: 'users/:id',
- name: 'manage.users.users.detail',
- component: AdminUsersDetail,
- props: true
- },
{
path: 'invitations',
name: 'manage.users.invitations.list',
@@ -241,6 +236,23 @@ export default new Router({
name: 'manage.moderation.domains.detail',
component: AdminDomainsDetail,
props: true
+ },
+ {
+ path: 'accounts',
+ name: 'manage.moderation.accounts.list',
+ component: AdminAccountsList,
+ props: (route) => {
+ return {
+ defaultQuery: route.query.q,
+
+ }
+ }
+ },
+ {
+ path: 'accounts/:id',
+ name: 'manage.moderation.accounts.detail',
+ component: AdminAccountsDetail,
+ props: true
}
]
},
diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue
new file mode 100644
index 0000000000..abed7baad2
--- /dev/null
+++ b/front/src/views/admin/moderation/AccountsDetail.vue
@@ -0,0 +1,426 @@
+<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.full_username">
+ <div class="segment-content">
+ <h2 class="ui header">
+ <i class="circular inverted user icon"></i>
+ <div class="content">
+ {{ object.full_username }}
+ <div class="sub header">
+ <template v-if="object.user">
+ <span class="ui tiny teal icon label">
+ <i class="home icon"></i>
+ <translate>Local account</translate>
+ </span>
+
+ </template>
+ <a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
+ <translate>Open profile</translate>
+ <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>Account data</translate>
+ </div>
+ </h3>
+ <table class="ui very basic table">
+ <tbody>
+ <tr>
+ <td>
+ <translate>Username</translate>
+ </td>
+ <td>
+ {{ object.preferred_username }}
+ </td>
+ </tr>
+ <tr v-if="!object.user">
+ <td>
+ <translate>Domain</translate>
+ </td>
+ <td>
+ <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
+ {{ object.domain }}
+ </router-link>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <translate>Display name</translate>
+ </td>
+ <td>
+ {{ object.name }}
+ </td>
+ </tr>
+ <tr v-if="object.user">
+ <td>
+ <translate>Email address</translate>
+ </td>
+ <td>
+ {{ object.user.email }}
+ </td>
+ </tr>
+ <tr v-if="object.user">
+ <td>
+ <translate>Login status</translate>
+ </td>
+ <td>
+ <div class="ui toggle checkbox" v-if="object.user.username != $store.state.auth.profile.username">
+ <input
+ @change="updateUser('is_active')"
+ v-model="object.user.is_active" type="checkbox">
+ <label>
+ <translate v-if="object.user.is_active" key="1">Enabled</translate>
+ <translate v-else key="2">Disabled</translate>
+ </label>
+ </div>
+ <translate v-else-if="object.user.is_active" key="1">Enabled</translate>
+ <translate v-else key="2">Disabled</translate>
+ </td>
+ </tr>
+ <tr v-if="object.user">
+ <td>
+ <translate>Permissions</translate>
+ </td>
+ <td>
+ <select
+ @change="updateUser('permissions')"
+ v-model="permissions"
+ multiple
+ class="ui search selection dropdown">
+ <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <translate>Type</translate>
+ </td>
+ <td>
+ {{ object.type }}
+ </td>
+ </tr>
+ <tr v-if="!object.user">
+ <td>
+ <translate>First seen</translate>
+ </td>
+ <td>
+ <human-date :date="object.creation_date"></human-date>
+ </td>
+ </tr>
+ <tr v-if="!object.user">
+ <td>
+ <translate>Last checked</translate>
+ </td>
+ <td>
+ <human-date v-if="object.last_fetch_date" :date="object.last_fetch_date"></human-date>
+ <translate v-else>N/A</translate>
+ </td>
+ </tr>
+ <tr v-if="object.user">
+ <td>
+ <translate>Sign-up date</translate>
+ </td>
+ <td>
+ <human-date :date="object.user.date_joined"></human-date>
+ </td>
+ </tr>
+ <tr v-if="object.user">
+ <td>
+ <translate>Last activity</translate>
+ </td>
+ <td>
+ <human-date :date="object.user.last_activity"></human-date>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </section>
+ </div>
+ <div class="column">
+ <section>
+ <h3 class="ui header">
+ <i class="feed icon"></i>
+ <div class="content">
+ <translate>Activity</translate>
+ <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>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>
+ <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 v-if="!object.user">
+ <td>
+ <translate>Cached size</translate>
+ </td>
+ <td>
+ {{ stats.media_downloaded_size | humanSize }}
+ </td>
+ </tr>
+ <tr v-if="object.user">
+ <td>
+ <translate>Upload quota</translate>
+ <span :data-tooltip="labels.uploadQuota"><i class="question circle icon"></i></span>
+ </td>
+ <td>
+ <div class="ui right labeled input">
+ <input
+ class="ui input"
+ @change="updateUser('upload_quota', true)"
+ v-model.number="object.user.upload_quota"
+ step="100"
+ type="number" />
+ <div class="ui basic label">
+ <translate>MB</translate>
+ </div>
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <translate>Total size</translate>
+ </td>
+ <td>
+ {{ stats.media_total_size | humanSize }}
+ </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>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>
+ </tbody>
+ </table>
+
+ </section>
+ </div>
+ </div>
+ </div>
+
+ </template>
+ </main>
+</template>
+
+<script>
+import axios from "axios"
+import logger from "@/logging"
+import lodash from '@/lodash'
+import $ from "jquery"
+
+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/accounts/" + this.id + "/"
+ axios.get(url).then(response => {
+ self.object = response.data
+ self.isLoading = false
+ if (response.data.user) {
+ self.allPermissions.forEach(p => {
+ if (self.object.user.permissions[p.code]) {
+ self.permissions.push(p.code)
+ }
+ })
+ }
+ })
+ },
+ fetchStats() {
+ var self = this
+ this.isLoadingStats = true
+ let url = "manage/accounts/" + 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()
+ },
+
+ updateUser(attr, toNull) {
+ let newValue = this.object.user[attr]
+ if (toNull && !newValue) {
+ newValue = null
+ }
+ let params = {}
+ if (attr === "permissions") {
+ params["permissions"] = {}
+ this.allPermissions.forEach(p => {
+ params["permissions"][p.code] = this.permissions.indexOf(p.code) > -1
+ })
+ } else {
+ params[attr] = newValue
+ }
+ axios.patch(`manage/users/users/${this.object.user.id}/`, params).then(
+ response => {
+ logger.default.info(
+ `${attr} was updated succcessfully to ${newValue}`
+ )
+ },
+ error => {
+ logger.default.error(
+ `Error while setting ${attr} to ${newValue}`,
+ error
+ )
+ }
+ )
+ }
+ },
+ 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 account"),
+ uploadQuota: this.$gettext(
+ "Determine how much content the user can upload. Leave empty to use the default value of the instance."
+ ),
+ }
+ },
+ allPermissions() {
+ return [
+ {
+ code: "library",
+ label: this.$gettext("Library")
+ },
+ {
+ code: "moderation",
+ label: this.$gettext("Moderation")
+ },
+ {
+ code: "settings",
+ label: this.$gettext("Settings")
+ }
+ ]
+ }
+ },
+ watch: {
+ object () {
+ this.$nextTick(() => {
+ $(this.$el).find("select.dropdown").dropdown()
+ })
+ }
+ }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/admin/moderation/AccountsList.vue b/front/src/views/admin/moderation/AccountsList.vue
new file mode 100644
index 0000000000..877c96c5ee
--- /dev/null
+++ b/front/src/views/admin/moderation/AccountsList.vue
@@ -0,0 +1,33 @@
+<template>
+ <main v-title="labels.accounts">
+ <section class="ui vertical stripe segment">
+ <h2 class="ui header"><translate>Accounts</translate></h2>
+ <div class="ui hidden divider"></div>
+ <accounts-table :update-url="true" :default-query="defaultQuery"></accounts-table>
+ </section>
+ </main>
+</template>
+
+<script>
+import AccountsTable from "@/components/manage/moderation/AccountsTable"
+
+export default {
+ components: {
+ AccountsTable
+ },
+ props: {
+ defaultQuery: {type: String, required: false},
+ },
+ computed: {
+ labels() {
+ return {
+ accounts: this.$gettext("Accounts")
+ }
+ }
+ }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/admin/moderation/Base.vue b/front/src/views/admin/moderation/Base.vue
index d4487339d3..e0bf6c1adc 100644
--- a/front/src/views/admin/moderation/Base.vue
+++ b/front/src/views/admin/moderation/Base.vue
@@ -1,9 +1,13 @@
<template>
- <div class="main pusher" v-title="labels.manageDomains">
+ <div class="main pusher" v-title="labels.moderation">
<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>
+ <router-link
+ class="ui item"
+ :to="{name: 'manage.moderation.accounts.list'}"><translate>Accounts</translate></router-link>
+
</nav>
<router-view :key="$route.fullPath"></router-view>
</div>
@@ -14,7 +18,7 @@ export default {
computed: {
labels() {
return {
- manageDomains: this.$gettext("Manage domains"),
+ moderation: this.$gettext("Moderation"),
secondaryMenu: this.$gettext("Secondary menu")
}
}
diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue
index 71007a4560..1adb1c3055 100644
--- a/front/src/views/admin/moderation/DomainsDetail.vue
+++ b/front/src/views/admin/moderation/DomainsDetail.vue
@@ -115,7 +115,11 @@
<tbody>
<tr>
<td>
- <translate>Known users</translate>
+ <router-link
+ :to="{name: 'manage.moderation.accounts.list', query: {q: 'domain:' + object.name }}">
+ <translate>Known accounts</translate>
+ </router-link>
+
</td>
<td>
{{ stats.actors }}
@@ -169,58 +173,58 @@
<tbody>
<tr>
<td>
- <translate>Artists</translate>
+ <translate>Cached size</translate>
</td>
<td>
- {{ stats.artists }}
+ {{ stats.media_downloaded_size | humanSize }}
</td>
</tr>
<tr>
<td>
- <translate>Albums</translate>
+ <translate>Total size</translate>
</td>
<td>
- {{ stats.albums}}
+ {{ stats.media_total_size | humanSize }}
</td>
</tr>
<tr>
<td>
- <translate>Tracks</translate>
+ <translate>Libraries</translate>
</td>
<td>
- {{ stats.tracks }}
+ {{ stats.libraries }}
</td>
</tr>
<tr>
<td>
- <translate>Libraries</translate>
+ <translate>Uploads</translate>
</td>
<td>
- {{ stats.libraries }}
+ {{ stats.uploads }}
</td>
</tr>
<tr>
<td>
- <translate>Uploads</translate>
+ <translate>Artists</translate>
</td>
<td>
- {{ stats.uploads }}
+ {{ stats.artists }}
</td>
</tr>
<tr>
<td>
- <translate>Cached size</translate>
+ <translate>Albums</translate>
</td>
<td>
- {{ stats.media_downloaded_size | humanSize }}
+ {{ stats.albums}}
</td>
</tr>
<tr>
<td>
- <translate>Total size</translate>
+ <translate>Tracks</translate>
</td>
<td>
- {{ stats.media_total_size | humanSize }}
+ {{ stats.tracks }}
</td>
</tr>
</tbody>
diff --git a/front/src/views/admin/users/UsersDetail.vue b/front/src/views/admin/users/UsersDetail.vue
deleted file mode 100644
index 7eaafb6cb7..0000000000
--- a/front/src/views/admin/users/UsersDetail.vue
+++ /dev/null
@@ -1,216 +0,0 @@
-<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', 'center', 'aligned', 'stripe', 'segment']" v-title="object.username">
- <div class="segment-content">
- <h2 class="ui center aligned icon header">
- <i class="circular inverted user red icon"></i>
- <div class="content">
- @{{ object.username }}
- </div>
- </h2>
- </div>
- <div class="ui hidden divider"></div>
- <div class="ui one column centered grid">
- <table class="ui collapsing very basic table">
- <tbody>
- <tr>
- <td>
- <translate>Name</translate>
- </td>
- <td>
- {{ object.name }}
- </td>
- </tr>
- <tr>
- <td>
- <translate>Email address</translate>
- </td>
- <td>
- {{ object.email }}
- </td>
- </tr>
- <tr>
- <td>
- <translate>Sign-up</translate>
- </td>
- <td>
- <human-date :date="object.date_joined"></human-date>
- </td>
- </tr>
- <tr>
- <td>
- <translate>Last activity</translate>
- </td>
- <td>
- <human-date v-if="object.last_activity" :date="object.last_activity"></human-date>
- <template v-else><translate>N/A</translate></template>
- </td>
- </tr>
- <tr>
- <td>
- <translate>Account active</translate>
- <span :data-tooltip="labels.inactive"><i class="question circle icon"></i></span>
- </td>
- <td>
- <div class="ui toggle checkbox">
- <input
- @change="update('is_active')"
- v-model="object.is_active" type="checkbox">
- <label></label>
- </div>
- </td>
- </tr>
- <tr>
- <td>
- <translate>Permissions</translate>
- </td>
- <td>
- <select
- @change="update('permissions')"
- v-model="permissions"
- multiple
- class="ui search selection dropdown">
- <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
- </select>
- </td>
- </tr>
- <tr>
- <td>
- <translate>Upload quota</translate>
- <span :data-tooltip="labels.uploadQuota"><i class="question circle icon"></i></span>
- </td>
- <td>
- <div class="ui right labeled input">
- <input
- class="ui input"
- @change="update('upload_quota', true)"
- v-model.number="object.upload_quota"
- step="100"
- type="number" />
- <div class="ui basic label">
- <translate>MB</translate>
- </div>
- </div>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <div class="ui hidden divider"></div>
- <button @click="fetchData" class="ui basic button"><translate>Refresh</translate></button>
- </section>
- </template>
- </main>
-</template>
-
-<script>
-import $ from "jquery"
-import axios from "axios"
-import logger from "@/logging"
-
-export default {
- props: ["id"],
- data() {
- return {
- isLoading: true,
- object: null,
- permissions: []
- }
- },
- created() {
- this.fetchData()
- },
- methods: {
- fetchData() {
- var self = this
- this.isLoading = true
- let url = "manage/users/users/" + this.id + "/"
- axios.get(url).then(response => {
- self.object = response.data
- self.permissions = []
- self.allPermissions.forEach(p => {
- if (self.object.permissions[p.code]) {
- self.permissions.push(p.code)
- }
- })
- self.isLoading = false
- })
- },
- update(attr, toNull) {
- let newValue = this.object[attr]
- if (toNull && !newValue) {
- newValue = null
- }
- let params = {}
- if (attr === "permissions") {
- params["permissions"] = {}
- this.allPermissions.forEach(p => {
- params["permissions"][p.code] = this.permissions.indexOf(p.code) > -1
- })
- } else {
- params[attr] = newValue
- }
- axios.patch("manage/users/users/" + this.id + "/", params).then(
- response => {
- logger.default.info(
- `${attr} was updated succcessfully to ${newValue}`
- )
- },
- error => {
- logger.default.error(
- `Error while setting ${attr} to ${newValue}`,
- error
- )
- }
- )
- }
- },
- computed: {
- labels() {
- return {
- inactive: this.$gettext(
- "Determine if the user account is active or not. Inactive users cannot login or use the service."
- ),
- uploadQuota: this.$gettext(
- "Determine how much content the user can upload. Leave empty to use the default value of the instance."
- )
- }
- },
- allPermissions() {
- return [
- {
- code: "upload",
- label: this.$gettext("Upload")
- },
- {
- code: "library",
- label: this.$gettext("Library")
- },
- {
- code: "federation",
- label: this.$gettext("Federation")
- },
- {
- code: "settings",
- label: this.$gettext("Settings")
- }
- ]
- }
- },
- watch: {
- object() {
- this.$nextTick(() => {
- $("select.dropdown").dropdown()
- })
- }
- }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
--
GitLab