Admin UI to list and manage remote and local accounts

merge-requests/552/head
Eliot Berriot 2019-01-03 17:10:02 +01:00
rodzic b1194e50de
commit e186c6bb06
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: DD6965E2476E5C27
18 zmienionych plików z 797 dodań i 283 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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>
&nbsp;
</template>
<a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
<translate>Open profile</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
</div>
</h2>
</div>
</section>
<div class="ui vertical stripe segment">
<div class="ui stackable three column grid">
<div class="column">
<section>
<h3 class="ui header">
<i class="info icon"></i>
<div class="content">
<translate>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>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr>
<td>
<translate>Emitted messages</translate>
</td>
<td>
{{ stats.outbox_activities}}
</td>
</tr>
<tr>
<td>
<translate>Received library follows</translate>
</td>
<td>
{{ stats.received_library_follows}}
</td>
</tr>
<tr>
<td>
<translate>Emitted library follows</translate>
</td>
<td>
{{ stats.emitted_library_follows}}
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="music icon"></i>
<div class="content">
<translate>Audio content</translate>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr 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>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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,26 +173,18 @@
<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}}
</td>
</tr>
<tr>
<td>
<translate>Tracks</translate>
</td>
<td>
{{ stats.tracks }}
{{ stats.media_total_size | humanSize }}
</td>
</tr>
<tr>
@ -209,18 +205,26 @@
</tr>
<tr>
<td>
<translate>Cached size</translate>
<translate>Artists</translate>
</td>
<td>
{{ stats.media_downloaded_size | humanSize }}
{{ stats.artists }}
</td>
</tr>
<tr>
<td>
<translate>Total size</translate>
<translate>Albums</translate>
</td>
<td>
{{ stats.media_total_size | humanSize }}
{{ stats.albums}}
</td>
</tr>
<tr>
<td>
<translate>Tracks</translate>
</td>
<td>
{{ stats.tracks }}
</td>
</tr>
</tbody>

Wyświetl plik

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