diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index ad88422e4f97f5259277c9b40617e43f9361be08..35b15b6678f5537da7a6f8d2b0f0c8d12c5100d5 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 dfb901924fe7d2d9b112585891f8636a161cd985..51648298a1ad92e96289ffbb08c5de3d57c52333 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 3b06fb84850bd581c87ebe78dffb3b7433f0f83e..76d0cf05fc49fb789e10385ad8dd4cb3f3e719e1 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 ddd4fe571479ba2fd48e7c410381ec9f0103b9b2..0697c6c14e3609fc32c508807312d47913e11407 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 07bb4bae4c23ceb83033bdf434f619fa3e400572..79650301e9e68acb01b32c754a249e46b3d30102 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 2936750484a1642edc2ab0c0c84f12c39208e8c0..f59293b6780b32531fb71d43a8d923486e37ea92 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 83d49cd66a84b1ec46518bb59bd7efe9cce94aa3..803820b489350fcbf795abb424bace0f3e3b614c 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 fdd3406faa00fe42fc78aeec5dac2d34de5c4b2f..7987b054a44213976944251afe7c152e7043ad82 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 0000000000000000000000000000000000000000..8750b4ec97a0497f7033446857cda80055bad63c --- /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 33b2433cb5c4b1f6917d151a9d972421d024edab..974ca392d9bea3fc59c92a07aa28e5dc1288e50e 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 170436b7a1ca3a540b3c2fe19582c60b6db26c5c..8b03becbbd8e7fb7b3bd38eaa673d9b01615a728 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 c982c9ad7ffedea54bae8664569166ad8e705c09..9d237c9161bb14fb26339f18de51399ac924ac11 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 9d4b46917afa5a12cc1efedd5b2c175de9e8bee4..492cbd6171c3e71b7e1626f410103311cb603bec 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 0000000000000000000000000000000000000000..abed7baad28b92dd5fac2b72e2947c90504edd11 --- /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 0000000000000000000000000000000000000000..877c96c5ee548615fdfb4720c07a4e6b5c8af211 --- /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 d4487339d3f41bb665e67c7034fdf974f7a72095..e0bf6c1adcd41f3df44526fde5bcbf8ecbb9ce4a 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 71007a45604ee0e40235d385ca54d94b3115b298..1adb1c3055b063e21669ec97d276a047b277b9ae 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 7eaafb6cb761989d87779c20c546ac75094aa842..0000000000000000000000000000000000000000 --- 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>