Merge branch 'paginate-moderation-log' into 'master'

Add pagination to moderation logs

See merge request framasoft/mobilizon!914
This commit is contained in:
Thomas Citharel 2021-04-28 09:01:03 +00:00
commit 65ea6325cd
14 changed files with 283 additions and 126 deletions

View file

@ -5,17 +5,50 @@
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "ActionLogObject", "name": "ActionLogObject",
"possibleTypes": [ "possibleTypes": [
{
"name": "Comment"
},
{ {
"name": "Event" "name": "Event"
}, },
{ {
"name": "Comment" "name": "Person"
}, },
{ {
"name": "Report" "name": "Report"
}, },
{ {
"name": "ReportNote" "name": "ReportNote"
},
{
"name": "User"
}
]
},
{
"kind": "INTERFACE",
"name": "ActivityObject",
"possibleTypes": [
{
"name": "Comment"
},
{
"name": "Discussion"
},
{
"name": "Event"
},
{
"name": "Group"
},
{
"name": "Member"
},
{
"name": "Post"
},
{
"name": "Resource"
} }
] ]
}, },
@ -33,6 +66,18 @@
"name": "Application" "name": "Application"
} }
] ]
},
{
"kind": "INTERFACE",
"name": "Interactable",
"possibleTypes": [
{
"name": "Event"
},
{
"name": "Group"
}
]
} }
] ]
} }

View file

@ -6,28 +6,10 @@ import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants";
import { REFRESH_TOKEN } from "@/graphql/auth"; import { REFRESH_TOKEN } from "@/graphql/auth";
import { saveTokenData } from "@/utils/auth"; import { saveTokenData } from "@/utils/auth";
import { ApolloClient } from "apollo-client"; import { ApolloClient } from "apollo-client";
import introspectionQueryResultData from "../../fragmentTypes.json";
export const fragmentMatcher = new IntrospectionFragmentMatcher({ export const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData: { introspectionQueryResultData,
__schema: {
types: [
{
kind: "UNION",
name: "SearchResult",
possibleTypes: [
{ name: "Event" },
{ name: "Person" },
{ name: "Group" },
],
},
{
kind: "INTERFACE",
name: "Actor",
possibleTypes: [{ name: "Person" }, { name: "Group" }],
},
],
},
},
}); });
export async function refreshAccessToken( export async function refreshAccessToken(

View file

@ -158,45 +158,54 @@ export const CREATE_REPORT_NOTE = gql`
`; `;
export const LOGS = gql` export const LOGS = gql`
query { query ActionLogs($page: Int, $limit: Int) {
actionLogs { actionLogs(page: $page, limit: $limit) {
id elements {
action
actor {
id id
preferredUsername action
domain actor {
avatar {
id
url
}
}
object {
... on Report {
id
}
... on ReportNote {
report {
id
}
}
... on Event {
id
title
}
... on Person {
id id
preferredUsername preferredUsername
domain domain
name avatar {
id
url
}
} }
... on User { object {
id ... on Report {
email id
confirmedAt }
... on ReportNote {
report {
id
}
}
... on Event {
id
title
}
... on Person {
id
preferredUsername
domain
name
}
... on Group {
id
preferredUsername
domain
name
}
... on User {
id
email
confirmedAt
}
} }
insertedAt
} }
insertedAt total
} }
} }
`; `;

View file

@ -985,5 +985,7 @@
"Unable to update the profile. The avatar picture may be too heavy.": "Unable to update the profile. The avatar picture may be too heavy.", "Unable to update the profile. The avatar picture may be too heavy.": "Unable to update the profile. The avatar picture may be too heavy.",
"Unable to create the profile. The avatar picture may be too heavy.": "Unable to create the profile. The avatar picture may be too heavy.", "Unable to create the profile. The avatar picture may be too heavy.": "Unable to create the profile. The avatar picture may be too heavy.",
"Error while loading the preview": "Error while loading the preview", "Error while loading the preview": "Error while loading the preview",
"Instance feeds": "Instance feeds" "Instance feeds": "Instance feeds",
"{moderator} suspended group {profile}": "{moderator} suspended group {profile}",
"{moderator} has unsuspended group {profile}": "{moderator} has unsuspended group {profile}"
} }

View file

@ -1079,5 +1079,7 @@
"Unable to update the profile. The avatar picture may be too heavy.": "Impossible de mettre à jour le profil. L'image d'avatar est probablement trop lourde.", "Unable to update the profile. The avatar picture may be too heavy.": "Impossible de mettre à jour le profil. L'image d'avatar est probablement trop lourde.",
"Unable to create the profile. The avatar picture may be too heavy.": "Impossible de créer le profil. L'image d'avatar est probablement trop lourde.", "Unable to create the profile. The avatar picture may be too heavy.": "Impossible de créer le profil. L'image d'avatar est probablement trop lourde.",
"Error while loading the preview": "Erreur lors du chargement de l'aperçu", "Error while loading the preview": "Erreur lors du chargement de l'aperçu",
"Instance feeds": "Flux de l'instance" "Instance feeds": "Flux de l'instance",
"{moderator} suspended group {profile}": "{moderator} a suspendu le groupe {profile}",
"{moderator} has unsuspended group {profile}": "{moderator} a annulé la suspension du groupe {profile}"
} }

View file

@ -14,9 +14,9 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<section> <section v-if="actionLogs.total > 0 && actionLogs.elements.length > 0">
<ul v-if="actionLogs.length > 0"> <ul>
<li v-for="log in actionLogs" :key="log.id"> <li v-for="log in actionLogs.elements" :key="log.id">
<div class="box"> <div class="box">
<img <img
class="image" class="image"
@ -147,7 +147,10 @@
<b slot="title">{{ log.object.title }}</b> <b slot="title">{{ log.object.title }}</b>
</i18n> </i18n>
<i18n <i18n
v-else-if="log.action === ActionLogAction.ACTOR_SUSPENSION" v-else-if="
log.action === ActionLogAction.ACTOR_SUSPENSION &&
log.object.__typename == 'Person'
"
tag="span" tag="span"
path="{moderator} suspended profile {profile}" path="{moderator} suspended profile {profile}"
> >
@ -169,7 +172,10 @@
</router-link> </router-link>
</i18n> </i18n>
<i18n <i18n
v-else-if="log.action === ActionLogAction.ACTOR_UNSUSPENSION" v-else-if="
log.action === ActionLogAction.ACTOR_UNSUSPENSION &&
log.object.__typename == 'Person'
"
tag="span" tag="span"
path="{moderator} has unsuspended profile {profile}" path="{moderator} has unsuspended profile {profile}"
> >
@ -190,6 +196,56 @@
>{{ displayNameAndUsername(log.object) }} >{{ displayNameAndUsername(log.object) }}
</router-link> </router-link>
</i18n> </i18n>
<i18n
v-else-if="
log.action === ActionLogAction.ACTOR_SUSPENSION &&
log.object.__typename == 'Group'
"
tag="span"
path="{moderator} suspended group {profile}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{
name: RouteName.ADMIN_GROUP_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n>
<i18n
v-else-if="
log.action === ActionLogAction.ACTOR_UNSUSPENSION &&
log.object.__typename == 'Group'
"
tag="span"
path="{moderator} has unsuspended group {profile}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{
name: RouteName.ADMIN_GROUP_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n>
<i18n <i18n
v-else-if="log.action === ActionLogAction.USER_DELETION" v-else-if="log.action === ActionLogAction.USER_DELETION"
tag="span" tag="span"
@ -219,20 +275,31 @@
</div> </div>
</li> </li>
</ul> </ul>
<div v-else> <b-pagination
<b-message type="is-info">{{ $t("No moderation logs yet") }}</b-message> :total="actionLogs.total"
</div> v-model="page"
:per-page="LOGS_PER_PAGE"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</section> </section>
<div v-else>
<b-message type="is-info">{{ $t("No moderation logs yet") }}</b-message>
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue, Watch } from "vue-property-decorator";
import { IActionLog } from "@/types/report.model"; import { IActionLog } from "@/types/report.model";
import { LOGS } from "@/graphql/report"; import { LOGS } from "@/graphql/report";
import ReportCard from "@/components/Report/ReportCard.vue"; import ReportCard from "@/components/Report/ReportCard.vue";
import { ActionLogAction } from "@/types/enums"; import { ActionLogAction } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { displayNameAndUsername } from "../../types/actor"; import { displayNameAndUsername } from "../../types/actor";
import { Paginate } from "@/types/paginate";
@Component({ @Component({
components: { components: {
@ -242,17 +309,39 @@ import { displayNameAndUsername } from "../../types/actor";
actionLogs: { actionLogs: {
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
query: LOGS, query: LOGS,
variables() {
return {
page: this.page,
limit: this.LOGS_PER_PAGE,
};
},
}, },
}, },
}) })
export default class ReportList extends Vue { export default class ReportList extends Vue {
actionLogs?: IActionLog[] = []; actionLogs?: Paginate<IActionLog> = { total: 0, elements: [] };
page = parseInt((this.$route.query.page as string) || "1", 10);
LOGS_PER_PAGE = 10;
ActionLogAction = ActionLogAction; ActionLogAction = ActionLogAction;
RouteName = RouteName; RouteName = RouteName;
displayNameAndUsername = displayNameAndUsername; displayNameAndUsername = displayNameAndUsername;
mounted(): void {
this.page = parseInt((this.$route.query.page as string) || "1", 10);
}
@Watch("page")
triggerLoadMoreMemberPageChange(page: string): void {
this.$router.replace({
name: RouteName.REPORT_LOGS,
query: { ...this.$route.query, page },
});
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -265,4 +354,8 @@ img.image {
a { a {
text-decoration: none; text-decoration: none;
} }
section ul li {
margin: 0.5rem auto;
}
</style> </style>

View file

@ -27,7 +27,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}} %{context: %{current_user: %User{role: role}}}
) )
when is_moderator(role) do when is_moderator(role) do
with action_logs <- Mobilizon.Admin.list_action_logs(page, limit) do with %Page{elements: action_logs, total: total} <-
Mobilizon.Admin.list_action_logs(page, limit) do
action_logs = action_logs =
action_logs action_logs
|> Enum.map(fn %ActionLog{ |> Enum.map(fn %ActionLog{
@ -44,7 +45,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
end) end)
|> Enum.filter(& &1) |> Enum.filter(& &1)
{:ok, action_logs} {:ok, %Page{elements: action_logs, total: total}}
end end
end end

View file

@ -29,7 +29,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
Represents a group of actors Represents a group of actors
""" """
object :group do object :group do
interfaces([:actor, :interactable, :activity_object]) interfaces([:actor, :interactable, :activity_object, :action_log_object])
field(:id, :id, description: "Internal ID for this group") field(:id, :id, description: "Internal ID for this group")
field(:url, :string, description: "The ActivityPub actor's URL") field(:url, :string, description: "The ActivityPub actor's URL")

View file

@ -22,6 +22,14 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
field(:inserted_at, :datetime, description: "The time when the action was performed") field(:inserted_at, :datetime, description: "The time when the action was performed")
end end
@desc """
A paginated list of action logs
"""
object :paginated_action_log_list do
field(:elements, list_of(:action_log), description: "A list of action logs")
field(:total, :integer, description: "The total number of action logs in the list")
end
@desc """ @desc """
The different types of action log actions The different types of action log actions
""" """
@ -62,6 +70,9 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
%User{}, _ -> %User{}, _ ->
:user :user
%Actor{type: "Group"}, _ ->
:group
_, _ -> _, _ ->
nil nil
end) end)
@ -144,7 +155,7 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
object :admin_queries do object :admin_queries do
@desc "Get the list of action logs" @desc "Get the list of action logs"
field :action_logs, type: list_of(:action_log) do field :action_logs, type: :paginated_action_log_list do
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
resolve(&Admin.list_action_logs/3) resolve(&Admin.list_action_logs/3)

View file

@ -36,11 +36,10 @@ defmodule Mobilizon.Admin do
@doc """ @doc """
Returns the list of action logs. Returns the list of action logs.
""" """
@spec list_action_logs(integer | nil, integer | nil) :: [ActionLog.t()] @spec list_action_logs(integer | nil, integer | nil) :: Page.t()
def list_action_logs(page \\ nil, limit \\ nil) do def list_action_logs(page \\ nil, limit \\ nil) do
list_action_logs_query() list_action_logs_query()
|> Page.paginate(page, limit) |> Page.build_page(page, limit)
|> Repo.all()
end end
@doc """ @doc """

View file

@ -31,17 +31,20 @@ defmodule Mobilizon.GraphQL.Resolvers.AdminTest do
query = """ query = """
{ {
actionLogs { actionLogs {
action, total
actor { elements {
preferredUsername action,
}, actor {
object { preferredUsername
... on Report {
id,
status
}, },
... on ReportNote { object {
content ... on Report {
id,
status
},
... on ReportNote {
content
}
} }
} }
} }
@ -62,9 +65,10 @@ defmodule Mobilizon.GraphQL.Resolvers.AdminTest do
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["actionLogs"] |> length == 3 assert json_response(res, 200)["data"]["actionLogs"]["total"] == 3
assert json_response(res, 200)["data"]["actionLogs"]["elements"] |> length == 3
assert json_response(res, 200)["data"]["actionLogs"] == [ assert json_response(res, 200)["data"]["actionLogs"]["elements"] == [
%{ %{
"action" => "NOTE_DELETION", "action" => "NOTE_DELETION",
"actor" => %{"preferredUsername" => moderator_2.preferred_username}, "actor" => %{"preferredUsername" => moderator_2.preferred_username},

View file

@ -228,25 +228,28 @@ defmodule Mobilizon.GraphQL.Resolvers.CommentTest do
query = """ query = """
{ {
actionLogs { actionLogs {
action, total
actor { elements {
preferredUsername action,
}, actor {
object { preferredUsername
... on Report {
id,
status
}, },
... on ReportNote { object {
content ... on Report {
} id,
... on Event { status
id, },
title ... on ReportNote {
}, content
... on Comment { }
id, ... on Event {
text id,
title
},
... on Comment {
id,
text
}
} }
} }
} }
@ -260,7 +263,7 @@ defmodule Mobilizon.GraphQL.Resolvers.CommentTest do
refute json_response(res, 200)["errors"] refute json_response(res, 200)["errors"]
assert hd(json_response(res, 200)["data"]["actionLogs"]) == %{ assert hd(json_response(res, 200)["data"]["actionLogs"]["elements"]) == %{
"action" => "COMMENT_DELETION", "action" => "COMMENT_DELETION",
"actor" => %{"preferredUsername" => actor_moderator.preferred_username}, "actor" => %{"preferredUsername" => actor_moderator.preferred_username},
"object" => %{"text" => comment.text, "id" => to_string(comment.id)} "object" => %{"text" => comment.text, "id" => to_string(comment.id)}

View file

@ -1368,21 +1368,24 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
query = """ query = """
{ {
actionLogs { actionLogs {
action, total
actor { elements {
preferredUsername action,
}, actor {
object { preferredUsername
... on Report {
id,
status
}, },
... on ReportNote { object {
content ... on Report {
} id,
... on Event { status
id, },
title ... on ReportNote {
content
}
... on Event {
id,
title
}
} }
} }
} }
@ -1394,7 +1397,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
|> auth_conn(user_moderator) |> auth_conn(user_moderator)
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs")) |> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
assert hd(json_response(res, 200)["data"]["actionLogs"]) == %{ assert hd(json_response(res, 200)["data"]["actionLogs"]["elements"]) == %{
"action" => "EVENT_DELETION", "action" => "EVENT_DELETION",
"actor" => %{"preferredUsername" => actor_moderator.preferred_username}, "actor" => %{"preferredUsername" => actor_moderator.preferred_username},
"object" => %{"title" => event.title, "id" => to_string(event.id)} "object" => %{"title" => event.title, "id" => to_string(event.id)}

View file

@ -685,15 +685,18 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
@moderation_logs_query """ @moderation_logs_query """
{ {
actionLogs { actionLogs {
action, total
actor { elements {
id, action,
preferredUsername actor {
},
object {
...on Person {
id, id,
preferredUsername preferredUsername
},
object {
...on Person {
id,
preferredUsername
}
} }
} }
} }
@ -733,7 +736,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
|> auth_conn(modo) |> auth_conn(modo)
|> AbsintheHelpers.graphql_query(query: @moderation_logs_query) |> AbsintheHelpers.graphql_query(query: @moderation_logs_query)
actionlog = hd(res["data"]["actionLogs"]) actionlog = hd(res["data"]["actionLogs"]["elements"])
refute is_nil(actionlog) refute is_nil(actionlog)
assert actionlog["action"] == "ACTOR_SUSPENSION" assert actionlog["action"] == "ACTOR_SUSPENSION"
assert actionlog["actor"]["id"] == to_string(modo_actor_id) assert actionlog["actor"]["id"] == to_string(modo_actor_id)