diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 364a1fba18a276cef05a34976a95749408132dc9..58244b67f818a230ba6dd828e9b497f0197912f7 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -15,7 +15,7 @@ class NoneObject(object): NONE = NoneObject() -NULL_BOOLEAN_CHOICES = [ +BOOLEAN_CHOICES = [ (True, True), ("true", True), ("True", True), @@ -26,6 +26,8 @@ NULL_BOOLEAN_CHOICES = [ ("False", False), ("0", False), ("no", False), +] +NULL_BOOLEAN_CHOICES = BOOLEAN_CHOICES + [ ("None", NONE), ("none", NONE), ("Null", NONE), @@ -76,10 +78,18 @@ def clean_null_boolean_filter(v): return v +def clean_boolean_filter(v): + return CoerceChoiceField(choices=BOOLEAN_CHOICES).clean(v) + + def get_null_boolean_filter(name): return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} +def get_boolean_filter(name): + return {"handler": lambda v: Q(**{name: clean_boolean_filter(v)})} + + class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField): def valid_value(self, value): return True @@ -142,7 +152,7 @@ class MutationFilter(filters.FilterSet): "domain": {"to": "created_by__domain__name__iexact"}, "is_approved": get_null_boolean_filter("is_approved"), "target": {"handler": filter_target}, - "is_applied": {"to": "is_applied"}, + "is_applied": get_boolean_filter("is_applied"), }, ) ) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 6ea3b8c991c8941ba925152a17df75e25a7217a0..59577cd39d76c3080f617f9863f5293ab7d40c2a 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -5,6 +5,7 @@ import django_filters from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search from funkwhale_api.federation import models as federation_models @@ -348,9 +349,9 @@ class ManageReportFilterSet(filters.FilterSet): filter_fields={ "uuid": {"to": "uuid"}, "id": {"to": "id"}, - "is_handled": {"to": "is_handled"}, + "resolved": common_filters.get_boolean_filter("is_handled"), "domain": {"to": "target_owner__domain_id"}, - "type": {"to": "type"}, + "category": {"to": "type"}, "submitter": get_actor_filter("submitter"), "assigned_to": get_actor_filter("assigned_to"), "target_owner": get_actor_filter("target_owner"), diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 6f72a9c2fbd73c9e7160d8d7560ca3a878a47c54..6d9e0c0ecdd0becf1c8acc08d052351d788e1cee 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -93,7 +93,7 @@ <router-link v-if="$store.state.auth.availablePermissions['moderation']" class="item" - :to="{name: 'manage.moderation.domains.list'}"> + :to="{name: 'manage.moderation.reports.list'}"> <i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate> <div v-if="$store.state.ui.notifications.pendingReviewReports > 0" diff --git a/front/src/router/index.js b/front/src/router/index.js index af21b168b538cf19aed524ce4f9ae63dd260b33e..14ad20ab89160de823916af3ec9fc9cf22819d4d 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -447,7 +447,21 @@ export default new Router({ /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail" ), props: true - } + }, + { + path: "reports", + name: "manage.moderation.reports.list", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/ReportsList" + ), + props: route => { + return { + defaultQuery: route.query.q, + updateUrl: true + } + } + }, ] }, { diff --git a/front/src/views/admin/moderation/ReportsList.vue b/front/src/views/admin/moderation/ReportsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5781e1b41de9824058c4fc7b7870287a8e53cbd --- /dev/null +++ b/front/src/views/admin/moderation/ReportsList.vue @@ -0,0 +1,268 @@ +<template> + <main v-title="labels.accounts"> + <section class="ui vertical stripe segment"> + <h2 class="ui header"><translate translate-context="*/Moderation/Title,Name">Reports</translate></h2> + <div class="ui hidden divider"></div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> + <form @submit.prevent="search.query = $refs.search.value"> + <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> + </form> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label (Value is All/Resolved/Unresolved)">Status</translate></label> + <select class="ui dropdown" @change="addSearchToken('resolved', $event.target.value)" :value="getTokenValue('resolved', '')"> + <option value=""> + <translate translate-context="Content/*/Dropdown">All</translate> + </option> + <option value="yes"> + <translate translate-context="Content/*/*/Short">Resolved</translate> + </option> + <option value="no"> + <translate translate-context="Content/*/*/Short">Unresolved</translate> + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">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 translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> + <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> + </select> + </div> + </div> + </div> + <action-table + v-if="result" + :objects-data="result" + :actions="actions" + action-url="manage/moderation/reports/action/" + :filters="[]"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*">Submitter</translate></th> + <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> + <th><translate translate-context="*/*/*">Category</translate></th> + <th><translate translate-context="*/*/*">Status</translate></th> + <th><translate translate-context="*/*/*">Target</translate></th> + <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> + <th><translate translate-context="Content/*/*/Noun">Resolution date</translate></th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td v-if="scope.obj.submitter"> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.submitter.full_username }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('submitter', scope.obj.submitter.full_username)" :title="scope.obj.submitter.full_username">{{ scope.obj.submitter.preferred_username }}</span> + </td> + <td v-else="scope.obj.submitter"> + <span role="button" class="discrete link" @click="addSearchToken('submitter_email', scope.obj.submitter_email)" :title="scope.obj.submitter_email">{{ scope.obj.submitter_email }}</span> + </td> + <td v-if="scope.obj.submitter"> + <template v-if="!scope.obj.submitter.is_local"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.submitter.domain }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.submitter.domain)" :title="scope.obj.submitter.domain">{{ scope.obj.submitter.domain }}</span> + </template> + <span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.submitter.domain)"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + </td> + <td v-else> + <translate translate-context="*/*/*">N/A</translate> + </td> + <td> + <span role="button" @click="addSearchToken('category', scope.obj.type)"> + {{ scope.obj.type }} + </span> + </td> + <td> + <span v-if="scope.obj.is_handled" role="button" class="discrete link" @click="addSearchToken('resolved', 'yes')" > + <translate translate-context="Content/*/*/Short">Resolved</translate> + </span> + <span v-else role="button" class="discrete link" @click="addSearchToken('resolved', 'no')" > + <translate translate-context="Content/*/*/Short">Unesolved</translate> + </span> + </td> + <td> + {{ scope.obj.target }} + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date v-if="scope.obj.handled_date" :date="scope.obj.handled_date"></human-date> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </template> + </action-table> + </section> + </main> +</template> + +<script> + + +import axios from 'axios' +import _ from '@/lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import OrderingMixin from '@/components/mixins/Ordering' +import TranslationsMixin from '@/components/mixins/Translations' +// import EditCard from '@/components/library/EditCard' +import {normalizeQuery, parseTokens} from '@/search' +import SmartSearchMixin from '@/components/mixins/SmartSearch' +import ActionTable from '@/components/common/ActionTable' + + +export default { + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], + components: { + Pagination, + ActionTable + // EditCard + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 25, + search: { + query: this.defaultQuery, + tokens: parseTokens(normalizeQuery(this.defaultQuery)) + }, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'creation_date'], + ['applied_date', 'applied_date'], + ], + targets: { + track: {} + } + } + }, + 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 + this.result = null + axios.get('manage/moderation/reports/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + // self.fetchTargets() + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + fetchTargets () { + // we request target data via the API so we can display previous state + // additionnal data next to the edit card + let self = this + let typesAndIds = { + track: { + url: 'tracks/', + ids: [], + } + } + this.result.results.forEach((m) => { + if (!m.target || !typesAndIds[m.target.type]) { + return + } + typesAndIds[m.target.type]['ids'].push(m.target.id) + }) + Object.keys(typesAndIds).forEach((k) => { + let config = typesAndIds[k] + if (config.ids.length === 0) { + return + } + axios.get(config.url, {params: {id: _.uniq(config.ids), hidden: 'null'}}).then((response) => { + response.data.results.forEach((e) => { + self.$set(self.targets[k], e.id, { + payload: e, + currentState: edits.getCurrentStateForObj(e, edits.getConfigs.bind(self)()[k]) + }) + }) + }, error => { + self.errors = error.backendErrors + }) + }) + }, + selectPage: function (page) { + this.page = page + }, + handle (type, id, value) { + if (type === 'delete') { + this.exclude.push(id) + } + + this.result.results.forEach((e) => { + if (e.uuid === id) { + e.is_approved = value + } + }) + }, + getCurrentState (target) { + if (!target) { + return {} + } + if (this.targets[target.type] && this.targets[target.type][String(target.id)]) { + return this.targets[target.type][String(target.id)].currentState + } + return {} + } + }, + computed: { + labels () { + return { + searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…'), + reports: this.$pgettext('*/Moderation/Title,Name', "Reports"), + } + }, + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} + +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>