Merge branch 'feature/ui-reports' into 'master'
Feature/ui reports Closes #158 et #156 See merge request framasoft/mobilizon!182
This commit is contained in:
commit
f47670c85f
@ -11,7 +11,7 @@
|
||||
<script lang="ts">
|
||||
import NavBar from '@/components/NavBar.vue';
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants';
|
||||
import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from '@/constants';
|
||||
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
|
||||
import { ICurrentUser } from '@/types/current-user.model';
|
||||
import Footer from '@/components/Footer.vue';
|
||||
@ -46,14 +46,16 @@ export default class App extends Vue {
|
||||
const userId = localStorage.getItem(AUTH_USER_ID);
|
||||
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
|
||||
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
|
||||
const role = localStorage.getItem(AUTH_USER_ROLE);
|
||||
|
||||
if (userId && userEmail && accessToken) {
|
||||
if (userId && userEmail && accessToken && role) {
|
||||
return this.$apollo.mutate({
|
||||
mutation: UPDATE_CURRENT_USER_CLIENT,
|
||||
variables: {
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
isLoggedIn: true,
|
||||
role,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -74,6 +76,7 @@ export default class App extends Vue {
|
||||
@import "~bulma/sass/components/navbar.sass";
|
||||
@import "~bulma/sass/components/pagination.sass";
|
||||
@import "~bulma/sass/components/dropdown.sass";
|
||||
@import "~bulma/sass/components/breadcrumb.sass";
|
||||
@import "~bulma/sass/elements/box.sass";
|
||||
@import "~bulma/sass/elements/button.sass";
|
||||
@import "~bulma/sass/elements/container.sass";
|
||||
@ -84,6 +87,7 @@ export default class App extends Vue {
|
||||
@import "~bulma/sass/elements/tag.sass";
|
||||
@import "~bulma/sass/elements/title.sass";
|
||||
@import "~bulma/sass/elements/notification";
|
||||
@import "~bulma/sass/elements/table";
|
||||
@import "~bulma/sass/grid/_all.sass";
|
||||
@import "~bulma/sass/layout/_all.sass";
|
||||
|
||||
@ -100,6 +104,7 @@ export default class App extends Vue {
|
||||
@import "~buefy/src/scss/components/upload";
|
||||
@import "~buefy/src/scss/components/radio";
|
||||
@import "~buefy/src/scss/components/switch";
|
||||
@import "~buefy/src/scss/components/table";
|
||||
|
||||
.router-enter-active,
|
||||
.router-leave-active {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ApolloCache } from 'apollo-cache';
|
||||
import { NormalizedCacheObject } from 'apollo-cache-inmemory';
|
||||
import { ICurrentUserRole } from '@/types/current-user.model';
|
||||
|
||||
export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) {
|
||||
cache.writeData({
|
||||
@ -9,18 +10,20 @@ export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObjec
|
||||
id: null,
|
||||
email: null,
|
||||
isLoggedIn: false,
|
||||
role: ICurrentUserRole.USER,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
Mutation: {
|
||||
updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => {
|
||||
updateCurrentUser: (_, { id, email, isLoggedIn, role }, { cache }) => {
|
||||
const data = {
|
||||
currentUser: {
|
||||
id,
|
||||
email,
|
||||
isLoggedIn,
|
||||
role,
|
||||
__typename: 'CurrentUser',
|
||||
},
|
||||
};
|
||||
|
@ -5,7 +5,7 @@
|
||||
<div class="tag-container" v-if="event.tags">
|
||||
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-secondary">{{ tag.title }}</b-tag>
|
||||
</div>
|
||||
<img src="https://picsum.photos/g/400/225/?random">
|
||||
<img src="https://picsum.photos/g/400/225/?random" />
|
||||
</figure>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<translate
|
||||
v-if="!endsOn"
|
||||
:translate-params="{date: formatDate(beginsOn), time: formatTime(beginsOn)}"
|
||||
>The %{ date } at %{ time }</translate>
|
||||
<span v-if="!endsOn">{{ beginsOn | formatDateTimeString }}</span>
|
||||
<translate
|
||||
v-else-if="isSameDay()"
|
||||
:translate-params="{date: formatDate(beginsOn), startTime: formatTime(beginsOn), endTime: formatTime(endsOn)}"
|
||||
@ -21,11 +18,13 @@ export default class EventFullDate extends Vue {
|
||||
@Prop({ required: false }) endsOn!: string;
|
||||
|
||||
formatDate(value) {
|
||||
return value ? new Date(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : null;
|
||||
if (!this.$options.filters) return;
|
||||
return this.$options.filters.formatDateString(value);
|
||||
}
|
||||
|
||||
formatTime(value) {
|
||||
return value ? new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' }) : null;
|
||||
if (!this.$options.filters) return;
|
||||
return this.$options.filters.formatTimeString(value);
|
||||
}
|
||||
|
||||
isSameDay() {
|
||||
|
@ -44,6 +44,10 @@
|
||||
<router-link :to="{ name: ActorRouteName.CREATE_GROUP }" v-translate>Create group</router-link>
|
||||
</span>
|
||||
|
||||
<span class="navbar-item" v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR">
|
||||
<router-link :to="{ name: AdminRouteName.DASHBOARD }" v-translate>Administration</router-link>
|
||||
</span>
|
||||
|
||||
<a v-translate class="navbar-item" v-on:click="logout()">Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -71,10 +75,12 @@ import { LOGGED_PERSON } from '@/graphql/actor';
|
||||
import { IPerson } from '@/types/actor';
|
||||
import { CONFIG } from '@/graphql/config';
|
||||
import { IConfig } from '@/types/config.model';
|
||||
import { ICurrentUser } from '@/types/current-user.model';
|
||||
import { ICurrentUser, ICurrentUserRole } from '@/types/current-user.model';
|
||||
import Logo from '@/components/Logo.vue';
|
||||
import SearchField from '@/components/SearchField.vue';
|
||||
import { ActorRouteName } from '@/router/actor';
|
||||
import { AdminRouteName } from '@/router/admin';
|
||||
import { RouteName } from '@/router';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -98,9 +104,11 @@ export default class NavBar extends Vue {
|
||||
loggedPerson: IPerson | null = null;
|
||||
config!: IConfig;
|
||||
currentUser!: ICurrentUser;
|
||||
ICurrentUserRole = ICurrentUserRole;
|
||||
showNavbar: boolean = false;
|
||||
|
||||
ActorRouteName = ActorRouteName;
|
||||
AdminRouteName = AdminRouteName;
|
||||
|
||||
@Watch('currentUser')
|
||||
async onCurrentUserChanged() {
|
||||
@ -119,7 +127,8 @@ export default class NavBar extends Vue {
|
||||
async logout() {
|
||||
await logout(this.$apollo.provider.defaultClient);
|
||||
|
||||
return this.$router.push({ path: '/' });
|
||||
if (this.$route.name === RouteName.HOME) return;
|
||||
return this.$router.push({ name: RouteName.HOME });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
45
js/src/components/Report/ReportCard.vue
Normal file
45
js/src/components/Report/ReportCard.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="card" v-if="report">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48" v-if="report.reported.avatar">
|
||||
<img :src="report.reported.avatar.url" />
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">{{ report.reported.name }}</p>
|
||||
<p class="subtitle is-6">@{{ report.reported.preferredUsername }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content columns">
|
||||
<div class="column is-one-quarter box">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
|
||||
<div class="column box" v-if="report.event">
|
||||
<img class="image" v-if="report.event.picture" :src="report.event.picture.url" />
|
||||
<span>{{ report.event.title }}</span>
|
||||
</div>
|
||||
<div class="column box" v-if="report.reportContent">{{ report.reportContent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { IReport } from '@/types/report.model';
|
||||
import { EventRouteName } from '@/router/event';
|
||||
|
||||
@Component
|
||||
export default class ReportCard extends Vue {
|
||||
@Prop({ required: true }) report!: IReport;
|
||||
|
||||
EventRouteName = EventRouteName;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.content img.image {
|
||||
display: inline;
|
||||
height: 1.5em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
</style>
|
101
js/src/components/Report/ReportModal.vue
Normal file
101
js/src/components/Report/ReportModal.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head" v-if="title">
|
||||
<p class="modal-card-title">{{ title }}</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="modal-card-body is-flex"
|
||||
:class="{ 'is-titleless': !title }">
|
||||
<div class="media">
|
||||
<div
|
||||
class="media-left">
|
||||
<b-icon
|
||||
icon="alert"
|
||||
type="is-warning"
|
||||
size="is-large"/>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p>The report will be sent to the moderators of your instance.
|
||||
You can explain why you report this content below.</p>
|
||||
|
||||
<div class="control">
|
||||
<b-input
|
||||
v-model="content"
|
||||
type="textarea"
|
||||
@keyup.enter="confirm"
|
||||
placeholder="Additional comments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="outsideDomain">
|
||||
The content came from another server. Transfer an anonymous copy of the report ?
|
||||
</p>
|
||||
|
||||
<div class="control" v-if="outsideDomain">
|
||||
<b-switch v-model="forward">Transfer to {{ outsideDomain }}</b-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<button
|
||||
class="button"
|
||||
ref="cancelButton"
|
||||
@click="cancel('button')">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary"
|
||||
ref="confirmButton"
|
||||
@click="confirm">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { removeElement } from 'buefy/src/utils/helpers';
|
||||
|
||||
@Component({
|
||||
mounted() {
|
||||
this.$data.isActive = true;
|
||||
},
|
||||
})
|
||||
export default class ReportModal extends Vue {
|
||||
@Prop({ type: Function, default: () => {} }) onConfirm;
|
||||
@Prop({ type: String }) title;
|
||||
@Prop({ type: String, default: '' }) outsideDomain;
|
||||
@Prop({ type: String, default: 'Cancel' }) cancelText;
|
||||
@Prop({ type: String, default: 'Send the report' }) confirmText;
|
||||
|
||||
isActive: boolean = false;
|
||||
content: string = '';
|
||||
forward: boolean = false;
|
||||
|
||||
confirm() {
|
||||
this.onConfirm(this.content, this.forward);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the Dialog.
|
||||
*/
|
||||
close() {
|
||||
this.isActive = false;
|
||||
// Timeout for the animation complete before destroying
|
||||
setTimeout(() => {
|
||||
this.$destroy();
|
||||
removeElement(this.$el);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.modal-card .modal-card-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
@ -3,3 +3,4 @@ export const AUTH_REFRESH_TOKEN = 'auth-refresh-token';
|
||||
export const AUTH_USER_ID = 'auth-user-id';
|
||||
export const AUTH_USER_EMAIL = 'auth-user-email';
|
||||
export const AUTH_USER_ACTOR = 'auth-user-actor';
|
||||
export const AUTH_USER_ROLE = 'auth-user-role';
|
||||
|
19
js/src/filters/datetime.ts
Normal file
19
js/src/filters/datetime.ts
Normal file
@ -0,0 +1,19 @@
|
||||
function parseDateTime(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
function formatDateString(value: string): string {
|
||||
return parseDateTime(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatTimeString(value: string): string {
|
||||
return parseDateTime(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' });
|
||||
}
|
||||
|
||||
function formatDateTimeString(value: string): string {
|
||||
return parseDateTime(value).toLocaleTimeString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
export { formatDateString, formatTimeString, formatDateTimeString };
|
9
js/src/filters/index.ts
Normal file
9
js/src/filters/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { formatDateString, formatTimeString, formatDateTimeString } from './datetime';
|
||||
|
||||
export default {
|
||||
install(vue) {
|
||||
vue.filter('formatDateString', formatDateString);
|
||||
vue.filter('formatTimeString', formatTimeString);
|
||||
vue.filter('formatDateTimeString', formatDateTimeString);
|
||||
},
|
||||
};
|
@ -177,7 +177,7 @@ query($name:String!) {
|
||||
|
||||
export const CREATE_GROUP = gql`
|
||||
mutation CreateGroup(
|
||||
$creatorActorId: Int!,
|
||||
$creatorActorId: ID!,
|
||||
$preferredUsername: String!,
|
||||
$name: String!,
|
||||
$summary: String,
|
||||
|
19
js/src/graphql/admin.ts
Normal file
19
js/src/graphql/admin.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const DASHBOARD = gql`
|
||||
query {
|
||||
dashboard {
|
||||
lastPublicEventPublished {
|
||||
title,
|
||||
picture {
|
||||
alt
|
||||
url
|
||||
},
|
||||
},
|
||||
numberOfUsers,
|
||||
numberOfEvents,
|
||||
numberOfComments,
|
||||
numberOfReports
|
||||
}
|
||||
}
|
||||
`;
|
@ -7,7 +7,8 @@ mutation Login($email: String!, $password: String!) {
|
||||
refreshToken,
|
||||
user {
|
||||
id,
|
||||
email
|
||||
email,
|
||||
role
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -70,8 +70,8 @@ export const FETCH_EVENT = gql`
|
||||
},
|
||||
publishAt,
|
||||
category,
|
||||
online_address,
|
||||
phone_address,
|
||||
onlineAddress,
|
||||
phoneAddress,
|
||||
physicalAddress {
|
||||
${physicalAddressQuery}
|
||||
}
|
||||
@ -218,8 +218,8 @@ export const CREATE_EVENT = gql`
|
||||
},
|
||||
publishAt,
|
||||
category,
|
||||
online_address,
|
||||
phone_address,
|
||||
onlineAddress,
|
||||
phoneAddress,
|
||||
physicalAddress {
|
||||
${physicalAddressQuery}
|
||||
},
|
||||
@ -240,7 +240,7 @@ export const EDIT_EVENT = gql`
|
||||
$description: String,
|
||||
$beginsOn: DateTime,
|
||||
$endsOn: DateTime,
|
||||
$status: Int,
|
||||
$status: EventStatus,
|
||||
$visibility: EventVisibility
|
||||
$tags: [String],
|
||||
$picture: PictureInput,
|
||||
@ -280,8 +280,8 @@ export const EDIT_EVENT = gql`
|
||||
},
|
||||
publishAt,
|
||||
category,
|
||||
online_address,
|
||||
phone_address,
|
||||
onlineAddress,
|
||||
phoneAddress,
|
||||
physicalAddress {
|
||||
${physicalAddressQuery}
|
||||
},
|
||||
@ -296,7 +296,7 @@ export const EDIT_EVENT = gql`
|
||||
`;
|
||||
|
||||
export const JOIN_EVENT = gql`
|
||||
mutation JoinEvent($eventId: Int!, $actorId: Int!) {
|
||||
mutation JoinEvent($eventId: ID!, $actorId: ID!) {
|
||||
joinEvent(
|
||||
eventId: $eventId,
|
||||
actorId: $actorId
|
||||
@ -307,7 +307,7 @@ export const JOIN_EVENT = gql`
|
||||
`;
|
||||
|
||||
export const LEAVE_EVENT = gql`
|
||||
mutation LeaveEvent($eventId: Int!, $actorId: Int!) {
|
||||
mutation LeaveEvent($eventId: ID!, $actorId: ID!) {
|
||||
leaveEvent(
|
||||
eventId: $eventId,
|
||||
actorId: $actorId
|
||||
@ -320,9 +320,9 @@ export const LEAVE_EVENT = gql`
|
||||
`;
|
||||
|
||||
export const DELETE_EVENT = gql`
|
||||
mutation DeleteEvent($id: Int!, $actorId: Int!) {
|
||||
mutation DeleteEvent($eventId: ID!, $actorId: ID!) {
|
||||
deleteEvent(
|
||||
eventId: $id,
|
||||
eventId: $eventId,
|
||||
actorId: $actorId
|
||||
) {
|
||||
id
|
||||
|
@ -12,7 +12,7 @@ query {
|
||||
}`;
|
||||
|
||||
export const CREATE_FEED_TOKEN_ACTOR = gql`
|
||||
mutation createFeedToken($actor_id: Int!) {
|
||||
mutation createFeedToken($actor_id: ID!) {
|
||||
createFeedToken(actorId: $actor_id) {
|
||||
token,
|
||||
actor {
|
||||
|
161
js/src/graphql/report.ts
Normal file
161
js/src/graphql/report.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const REPORTS = gql`
|
||||
query Reports($status: ReportStatus) {
|
||||
reports(status: $status) {
|
||||
id,
|
||||
reported {
|
||||
id,
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
},
|
||||
reporter {
|
||||
id,
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
},
|
||||
event {
|
||||
id,
|
||||
uuid,
|
||||
title,
|
||||
picture {
|
||||
url
|
||||
}
|
||||
},
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const REPORT_FRAGMENT = gql`
|
||||
fragment ReportFragment on Report {
|
||||
id,
|
||||
reported {
|
||||
id,
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
},
|
||||
reporter {
|
||||
id,
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
},
|
||||
event {
|
||||
id,
|
||||
uuid,
|
||||
title,
|
||||
description,
|
||||
picture {
|
||||
url
|
||||
}
|
||||
},
|
||||
notes {
|
||||
id,
|
||||
content
|
||||
moderator {
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
},
|
||||
insertedAt
|
||||
},
|
||||
insertedAt,
|
||||
updatedAt,
|
||||
status,
|
||||
content
|
||||
}
|
||||
`;
|
||||
|
||||
export const REPORT = gql`
|
||||
query Report($id: ID!) {
|
||||
report(id: $id) {
|
||||
...ReportFragment
|
||||
}
|
||||
}
|
||||
${REPORT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CREATE_REPORT = gql`
|
||||
mutation CreateReport(
|
||||
$eventId: ID!,
|
||||
$reporterActorId: ID!,
|
||||
$reportedActorId: ID!,
|
||||
$content: String
|
||||
) {
|
||||
createReport(eventId: $eventId, reporterActorId: $reporterActorId, reportedActorId: $reportedActorId, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_REPORT = gql`
|
||||
mutation UpdateReport(
|
||||
$reportId: ID!,
|
||||
$moderatorId: ID!,
|
||||
$status: ReportStatus!
|
||||
) {
|
||||
updateReportStatus(reportId: $reportId, moderatorId: $moderatorId, status: $status) {
|
||||
...ReportFragment
|
||||
}
|
||||
}
|
||||
${REPORT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CREATE_REPORT_NOTE = gql`
|
||||
mutation CreateReportNote(
|
||||
$reportId: ID!,
|
||||
$moderatorId: ID!,
|
||||
$content: String!
|
||||
) {
|
||||
createReportNote(reportId: $reportId, moderatorId: $moderatorId, content: $content) {
|
||||
id,
|
||||
content,
|
||||
insertedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGS = gql`
|
||||
query {
|
||||
actionLogs {
|
||||
id,
|
||||
action,
|
||||
actor {
|
||||
id,
|
||||
preferredUsername
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
},
|
||||
object {
|
||||
...on Report {
|
||||
id
|
||||
},
|
||||
... on ReportNote {
|
||||
report {
|
||||
id,
|
||||
}
|
||||
}
|
||||
... on Event {
|
||||
id,
|
||||
title
|
||||
}
|
||||
},
|
||||
insertedAt
|
||||
}
|
||||
}
|
||||
`;
|
@ -31,12 +31,13 @@ query {
|
||||
id,
|
||||
email,
|
||||
isLoggedIn,
|
||||
role
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_CURRENT_USER_CLIENT = gql`
|
||||
mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!) {
|
||||
updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn) @client
|
||||
mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!, $role: UserRole!) {
|
||||
updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn, role: $role) @client
|
||||
}
|
||||
`;
|
||||
|
@ -7,6 +7,7 @@ import App from '@/App.vue';
|
||||
import router from '@/router';
|
||||
import { apolloProvider } from './vue-apollo';
|
||||
import { NotifierPlugin } from '@/plugins/notifier';
|
||||
import filters from '@/filters';
|
||||
|
||||
const translations = require('@/i18n/translations.json');
|
||||
|
||||
@ -14,6 +15,7 @@ Vue.config.productionTip = false;
|
||||
|
||||
Vue.use(Buefy);
|
||||
Vue.use(NotifierPlugin);
|
||||
Vue.use(filters);
|
||||
|
||||
const language = (window.navigator as any).userLanguage || window.navigator.language;
|
||||
|
||||
|
16
js/src/router/admin.ts
Normal file
16
js/src/router/admin.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { RouteConfig } from 'vue-router';
|
||||
import Dashboard from '@/views/Admin/Dashboard.vue';
|
||||
|
||||
export enum AdminRouteName {
|
||||
DASHBOARD = 'Dashboard',
|
||||
}
|
||||
|
||||
export const adminRoutes: RouteConfig[] = [
|
||||
{
|
||||
path: '/admin',
|
||||
name: AdminRouteName.DASHBOARD,
|
||||
component: Dashboard,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
];
|
@ -5,9 +5,11 @@ import Home from '@/views/Home.vue';
|
||||
import { UserRouteName, userRoutes } from './user';
|
||||
import { EventRouteName, eventRoutes } from '@/router/event';
|
||||
import { ActorRouteName, actorRoutes, MyAccountRouteName } from '@/router/actor';
|
||||
import { AdminRouteName, adminRoutes } from '@/router/admin';
|
||||
import { ErrorRouteName, errorRoutes } from '@/router/error';
|
||||
import { authGuardIfNeeded } from '@/router/guards/auth-guard';
|
||||
import Search from '@/views/Search.vue';
|
||||
import { ModerationRouteName, moderationRoutes } from '@/router/moderation';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
@ -35,6 +37,8 @@ export const RouteName = {
|
||||
...EventRouteName,
|
||||
...ActorRouteName,
|
||||
...MyAccountRouteName,
|
||||
...AdminRouteName,
|
||||
...ModerationRouteName,
|
||||
...ErrorRouteName,
|
||||
};
|
||||
|
||||
@ -46,6 +50,8 @@ const router = new Router({
|
||||
...userRoutes,
|
||||
...eventRoutes,
|
||||
...actorRoutes,
|
||||
...adminRoutes,
|
||||
...moderationRoutes,
|
||||
...errorRoutes,
|
||||
{
|
||||
path: '/search/:searchTerm/:searchType?',
|
||||
|
34
js/src/router/moderation.ts
Normal file
34
js/src/router/moderation.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { RouteConfig } from 'vue-router';
|
||||
import ReportList from '@/views/Moderation/ReportList.vue';
|
||||
import Report from '@/views/Moderation/Report.vue';
|
||||
import Logs from '@/views/Moderation/Logs.vue';
|
||||
|
||||
export enum ModerationRouteName {
|
||||
REPORTS = 'Reports',
|
||||
REPORT = 'Report',
|
||||
LOGS = 'Logs',
|
||||
}
|
||||
|
||||
export const moderationRoutes: RouteConfig[] = [
|
||||
{
|
||||
path: '/moderation/reports/:filter?',
|
||||
name: ModerationRouteName.REPORTS,
|
||||
component: ReportList,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/moderation/report/:reportId',
|
||||
name: ModerationRouteName.REPORT,
|
||||
component: Report,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/moderation/logs',
|
||||
name: ModerationRouteName.LOGS,
|
||||
component: Logs,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
];
|
9
js/src/types/admin.model.ts
Normal file
9
js/src/types/admin.model.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IEvent } from '@/types/event.model';
|
||||
|
||||
export interface IDashboard {
|
||||
lastPublicEventPublished: IEvent;
|
||||
numberOfUsers: number;
|
||||
numberOfEvents: number;
|
||||
numberOfComments: number;
|
||||
numberOfReports: number;
|
||||
}
|
@ -1,5 +1,12 @@
|
||||
export enum ICurrentUserRole {
|
||||
USER = 'USER',
|
||||
MODERATOR = 'MODERATOR',
|
||||
ADMINISTRATOR = 'ADMINISTRATOR',
|
||||
}
|
||||
|
||||
export interface ICurrentUser {
|
||||
id: number;
|
||||
email: string;
|
||||
isLoggedIn: boolean;
|
||||
role: ICurrentUserRole;
|
||||
}
|
||||
|
47
js/src/types/report.model.ts
Normal file
47
js/src/types/report.model.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { IActor, IPerson } from '@/types/actor';
|
||||
import { IEvent } from '@/types/event.model';
|
||||
|
||||
export enum ReportStatusEnum {
|
||||
OPEN = 'OPEN',
|
||||
CLOSED = 'CLOSED',
|
||||
RESOLVED = 'RESOLVED',
|
||||
}
|
||||
|
||||
export interface IReport extends IActionLogObject {
|
||||
id: string;
|
||||
reported: IActor;
|
||||
reporter: IPerson;
|
||||
event?: IEvent;
|
||||
content: string;
|
||||
notes: IReportNote[];
|
||||
insertedAt: Date;
|
||||
updatedAt: Date;
|
||||
status: ReportStatusEnum;
|
||||
}
|
||||
|
||||
export interface IReportNote extends IActionLogObject{
|
||||
id: string;
|
||||
content: string;
|
||||
moderator: IActor;
|
||||
}
|
||||
|
||||
export interface IActionLogObject {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export enum ActionLogAction {
|
||||
NOTE_CREATION = 'NOTE_CREATION',
|
||||
NOTE_DELETION = 'NOTE_DELETION',
|
||||
REPORT_UPDATE_CLOSED = 'REPORT_UPDATE_CLOSED',
|
||||
REPORT_UPDATE_OPENED = 'REPORT_UPDATE_OPENED',
|
||||
REPORT_UPDATE_RESOLVED = 'REPORT_UPDATE_RESOLVED',
|
||||
EVENT_DELETION = 'EVENT_DELETION',
|
||||
}
|
||||
|
||||
export interface IActionLog {
|
||||
id: string;
|
||||
object: IReport|IReportNote|IEvent;
|
||||
actor: IActor;
|
||||
action: ActionLogAction;
|
||||
insertedAt: Date;
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants';
|
||||
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from '@/constants';
|
||||
import { ILogin, IToken } from '@/types/login.model';
|
||||
import { UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
|
||||
import { onLogout } from '@/vue-apollo';
|
||||
import ApolloClient from 'apollo-client';
|
||||
import { ICurrentUserRole } from '@/types/current-user.model';
|
||||
|
||||
export function saveUserData(obj: ILogin) {
|
||||
localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`);
|
||||
localStorage.setItem(AUTH_USER_EMAIL, obj.user.email);
|
||||
localStorage.setItem(AUTH_USER_ROLE, obj.user.role);
|
||||
|
||||
saveTokenData(obj);
|
||||
}
|
||||
@ -17,7 +19,7 @@ export function saveTokenData(obj: IToken) {
|
||||
}
|
||||
|
||||
export function deleteUserData() {
|
||||
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN]) {
|
||||
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_ROLE]) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
@ -29,6 +31,7 @@ export function logout(apollo: ApolloClient<any>) {
|
||||
id: null,
|
||||
email: null,
|
||||
isLoggedIn: false,
|
||||
role: ICurrentUserRole.USER,
|
||||
},
|
||||
});
|
||||
|
||||
|
73
js/src/views/Admin/Dashboard.vue
Normal file
73
js/src/views/Admin/Dashboard.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<section class="container">
|
||||
<h1 class="title">Administration</h1>
|
||||
<div class="tile is-ancestor" v-if="dashboard">
|
||||
<div class="tile is-vertical is-4">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-vertical is-6">
|
||||
<article class="tile is-child box">
|
||||
<p class="title">{{ dashboard.numberOfEvents }}</p>
|
||||
<p class="subtitle">événements publiés</p>
|
||||
</article>
|
||||
<article class="tile is-child box">
|
||||
<p class="title">{{ dashboard.numberOfComments}}</p>
|
||||
<p class="subtitle">commentaires</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="tile is-parent is-vertical">
|
||||
<article class="tile is-child box">
|
||||
<p class="title">{{ dashboard.numberOfUsers }}</p>
|
||||
<p class="subtitle">utilisateurices</p>
|
||||
</article>
|
||||
<router-link :to="{ name: ModerationRouteName.REPORTS}">
|
||||
<article class="tile is-child box">
|
||||
<p class="title">{{ dashboard.numberOfReports }}</p>
|
||||
<p class="subtitle">signalements ouverts</p>
|
||||
</article>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent" v-if="dashboard.lastPublicEventPublished">
|
||||
<article class="tile is-child box">
|
||||
<p class="title">Dernier événement publié</p>
|
||||
<p class="subtitle">{{ dashboard.lastPublicEventPublished.title }}</p>
|
||||
<figure class="image is-4by3" v-if="dashboard.lastPublicEventPublished.picture">
|
||||
<img :src="dashboard.lastPublicEventPublished.picture.url" />
|
||||
</figure>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<article class="tile is-child box">
|
||||
<div class="content">
|
||||
<p class="title">Bienvenue sur votre espace d'administration</p>
|
||||
<p class="subtitle">With even more content</p>
|
||||
<div class="content">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam semper diam at erat pulvinar, at pulvinar felis blandit. Vestibulum volutpat tellus diam, consequat gravida libero rhoncus ut. Morbi maximus, leo sit amet vehicula eleifend, nunc dui porta orci, quis semper odio felis ut quam.</p>
|
||||
<p>Suspendisse varius ligula in molestie lacinia. Maecenas varius eget ligula a sagittis. Pellentesque interdum, nisl nec interdum maximus, augue diam porttitor lorem, et sollicitudin felis neque sit amet erat. Maecenas imperdiet felis nisi, fringilla luctus felis hendrerit sit amet. Aenean vitae gravida diam, finibus dignissim turpis. Sed eget varius ligula, at volutpat tortor.</p>
|
||||
<p>Integer sollicitudin, tortor a mattis commodo, velit urna rhoncus erat, vitae congue lectus dolor consequat libero. Donec leo ligula, maximus et pellentesque sed, gravida a metus. Cras ullamcorper a nunc ac porta. Aliquam ut aliquet lacus, quis faucibus libero. Quisque non semper leo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { DASHBOARD } from '@/graphql/admin';
|
||||
import { IDashboard } from '@/types/admin.model';
|
||||
import { ModerationRouteName } from '@/router/moderation';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
dashboard: {
|
||||
query: DASHBOARD,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class Dashboard extends Vue {
|
||||
dashboard!: IDashboard;
|
||||
ModerationRouteName = ModerationRouteName;
|
||||
}
|
||||
</script>
|
@ -53,8 +53,8 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="column sidebar">
|
||||
<div class="field has-addons" v-if="actorIsOrganizer()">
|
||||
<p class="control">
|
||||
<div class="field has-addons">
|
||||
<p class="control" v-if="actorIsOrganizer()">
|
||||
<router-link
|
||||
class="button"
|
||||
:to="{ name: 'EditEvent', params: {eventId: event.uuid}}"
|
||||
@ -62,11 +62,16 @@
|
||||
<translate>Edit</translate>
|
||||
</router-link>
|
||||
</p>
|
||||
<p class="control">
|
||||
<p class="control" v-if="actorIsOrganizer()">
|
||||
<a class="button is-danger" @click="openDeleteEventModal()">
|
||||
<translate>Delete</translate>
|
||||
</a>
|
||||
</p>
|
||||
<p class="control">
|
||||
<a class="button is-danger" @click="isReportModalActive = true">
|
||||
<translate>Report</translate>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="address-wrapper">
|
||||
<b-icon icon="map" />
|
||||
@ -224,6 +229,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<b-modal :active.sync="isReportModalActive" has-modal-card>
|
||||
<report-modal :on-confirm="reportEvent" title="Report this event" :outside-domain="event.organizerActor.domain" />
|
||||
</b-modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -241,6 +249,9 @@ import BIcon from 'buefy/src/components/icon/Icon.vue';
|
||||
import EventCard from '@/components/Event/EventCard.vue';
|
||||
import EventFullDate from '@/components/Event/EventFullDate.vue';
|
||||
import ActorLink from '@/components/Account/ActorLink.vue';
|
||||
import ReportModal from '@/components/Report/ReportModal.vue';
|
||||
import { IReport } from '@/types/report.model';
|
||||
import { CREATE_REPORT } from '@/graphql/report';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@ -249,6 +260,7 @@ import ActorLink from '@/components/Account/ActorLink.vue';
|
||||
EventCard,
|
||||
BIcon,
|
||||
DateCalendarIcon,
|
||||
ReportModal,
|
||||
// tslint:disable:space-in-parens
|
||||
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
|
||||
// tslint:enable
|
||||
@ -274,6 +286,7 @@ export default class Event extends Vue {
|
||||
loggedPerson!: IPerson;
|
||||
validationSent: boolean = false;
|
||||
showMap: boolean = false;
|
||||
isReportModalActive: boolean = false;
|
||||
|
||||
EventVisibility = EventVisibility;
|
||||
|
||||
@ -285,24 +298,47 @@ export default class Event extends Vue {
|
||||
type: 'is-danger',
|
||||
title: this.$gettext('Delete event'),
|
||||
message: this.$gettextInterpolate(
|
||||
`${prefix}` +
|
||||
'Are you sure you want to delete this event? This action cannot be reverted. <br /><br />' +
|
||||
'To confirm, type your event title "%{eventTitle}"',
|
||||
{ participants: this.event.participants.length, eventTitle: this.event.title },
|
||||
`${prefix}` +
|
||||
'Are you sure you want to delete this event? This action cannot be reverted. <br /><br />' +
|
||||
'To confirm, type your event title "%{eventTitle}"',
|
||||
{ participants: this.event.participants.length, eventTitle: this.event.title },
|
||||
),
|
||||
confirmText: this.$gettextInterpolate(
|
||||
'Delete %{eventTitle}',
|
||||
{ eventTitle: this.event.title },
|
||||
'Delete %{eventTitle}',
|
||||
{ eventTitle: this.event.title },
|
||||
),
|
||||
inputAttrs: {
|
||||
placeholder: this.event.title,
|
||||
pattern: this.event.title,
|
||||
},
|
||||
|
||||
onConfirm: () => this.deleteEvent(),
|
||||
});
|
||||
}
|
||||
|
||||
async reportEvent(content: string, forward: boolean) {
|
||||
this.isReportModalActive = false;
|
||||
const eventTitle = this.event.title;
|
||||
try {
|
||||
await this.$apollo.mutate<IReport>({
|
||||
mutation: CREATE_REPORT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
reporterActorId: this.loggedPerson.id,
|
||||
reportedActorId: this.event.organizerActor.id,
|
||||
content,
|
||||
},
|
||||
});
|
||||
this.$buefy.notification.open({
|
||||
message: this.$gettextInterpolate('Event %{eventTitle} reported', { eventTitle }),
|
||||
type: 'is-success',
|
||||
position: 'is-bottom-right',
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async joinEvent() {
|
||||
try {
|
||||
await this.$apollo.mutate<{ joinEvent: IParticipant }>({
|
||||
@ -408,7 +444,7 @@ export default class Event extends Vue {
|
||||
await this.$apollo.mutate<IParticipant>({
|
||||
mutation: DELETE_EVENT,
|
||||
variables: {
|
||||
id: this.event.id,
|
||||
eventId: this.event.id,
|
||||
actorId: this.loggedPerson.id,
|
||||
},
|
||||
});
|
||||
|
80
js/src/views/Moderation/Logs.vue
Normal file
80
js/src/views/Moderation/Logs.vue
Normal file
@ -0,0 +1,80 @@
|
||||
import {ReportStatusEnum} from "@/types/report.model";
|
||||
<template>
|
||||
<section class="container">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
|
||||
<li class="is-active"><router-link :to="{ name: ModerationRouteName.LOGS }" aria-current="page">Logs</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<ul v-if="actionLogs.length > 0">
|
||||
<li v-for="log in actionLogs">
|
||||
<div class="box">
|
||||
<img class="image" :src="log.actor.avatar.url" />
|
||||
<span>@{{ log.actor.preferredUsername }}</span>
|
||||
<span v-if="log.action === ActionLogAction.REPORT_UPDATE_CLOSED">
|
||||
closed <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link>
|
||||
</span>
|
||||
<span v-else-if="log.action === ActionLogAction.REPORT_UPDATE_OPENED">
|
||||
reopened <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link>
|
||||
</span>
|
||||
<span v-else-if="log.action === ActionLogAction.REPORT_UPDATE_RESOLVED">
|
||||
marked <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link> as resolved
|
||||
</span>
|
||||
<span v-else-if="log.action === ActionLogAction.NOTE_CREATION">
|
||||
added a note on
|
||||
<router-link v-if="log.object.report" :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.report.id } }">report #{{ log.object.report.id }}</router-link>
|
||||
<span v-else>a non-existent report</span>
|
||||
</span>
|
||||
<span v-else-if="log.action === ActionLogAction.EVENT_DELETION">
|
||||
deleted an event named « {{ log.object.title }} »
|
||||
</span>
|
||||
<br />
|
||||
<small>{{ log.insertedAt | formatDateTimeString }}</small>
|
||||
</div>
|
||||
<!-- <pre>{{ log }}</pre>-->
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else>
|
||||
<b-message type="is-info">No moderation logs yet</b-message>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { IActionLog, ActionLogAction } from '@/types/report.model';
|
||||
import { LOGS } from '@/graphql/report';
|
||||
import ReportCard from '@/components/Report/ReportCard.vue';
|
||||
import { AdminRouteName } from '@/router/admin';
|
||||
import { ModerationRouteName } from '@/router/moderation';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
ReportCard,
|
||||
},
|
||||
apollo: {
|
||||
actionLogs: {
|
||||
query: LOGS,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class ReportList extends Vue {
|
||||
|
||||
actionLogs?: IActionLog[] = [];
|
||||
|
||||
ActionLogAction = ActionLogAction;
|
||||
AdminRouteName = AdminRouteName;
|
||||
ModerationRouteName = ModerationRouteName;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.container li {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
img.image {
|
||||
display: inline;
|
||||
height: 1.5em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
</style>
|
271
js/src/views/Moderation/Report.vue
Normal file
271
js/src/views/Moderation/Report.vue
Normal file
@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<section class="container">
|
||||
<b-message title="Error" type="is-danger" v-for="error in errors" :key="error">{{ error }}</b-message>
|
||||
<div class="container" v-if="report">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
|
||||
<li><router-link :to="{ name: ModerationRouteName.REPORTS }">Reports</router-link></li>
|
||||
<li class="is-active"><router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">Report</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="buttons">
|
||||
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">Mark as resolved</b-button>
|
||||
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">Reopen</b-button>
|
||||
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">Close</b-button>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="table-container">
|
||||
<table class="box table is-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Compte signalé</td>
|
||||
<td>
|
||||
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
|
||||
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Signalé par</td>
|
||||
<td>
|
||||
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
|
||||
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Signalé</td>
|
||||
<td>{{ report.insertedAt | formatDateTimeString }}</td>
|
||||
</tr>
|
||||
<tr v-if="report.updatedAt !== report.insertedAt">
|
||||
<td>Mis à jour</td>
|
||||
<td>{{ report.updatedAt | formatDateTimeString }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Statut</td>
|
||||
<td>
|
||||
<span v-if="report.status === ReportStatusEnum.OPEN">Ouvert</span>
|
||||
<span v-else-if="report.status === ReportStatusEnum.CLOSED">Fermé</span>
|
||||
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">Résolu</span>
|
||||
<span v-else>Inconnu</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<p v-if="report.content">{{ report.content }}</p>
|
||||
<p v-else>Pas de commentaire</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" v-if="report.event">
|
||||
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: report.event.uuid }}">
|
||||
<h3 class="title">{{ report.event.title }}</h3>
|
||||
<p v-html="report.event.description"></p>
|
||||
</router-link>
|
||||
<b-button
|
||||
tag="router-link"
|
||||
type="is-primary"
|
||||
:to="{ name: EventRouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
|
||||
icon-left="pencil"
|
||||
size="is-small">Edit</b-button>
|
||||
<b-button
|
||||
type="is-danger"
|
||||
@click="confirmDelete()"
|
||||
icon-left="delete"
|
||||
size="is-small">Delete</b-button>
|
||||
</div>
|
||||
|
||||
<h2 class="title" v-if="report.notes.length > 0">Notes</h2>
|
||||
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`">
|
||||
<p>{{ note.content }}</p>
|
||||
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: note.moderator.preferredUsername } }">
|
||||
<img class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
|
||||
</router-link><br />
|
||||
<small><a :href="`#note-${note.id}`" v-if="note.insertedAt">{{ note.insertedAt | formatDateTimeString }}</a></small>
|
||||
</div>
|
||||
|
||||
<form @submit="addNote()">
|
||||
<b-field label="Nouvelle note">
|
||||
<b-input type="textarea" v-model="noteContent"></b-input>
|
||||
</b-field>
|
||||
<b-button type="submit" @click="addNote">Ajouter une note</b-button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { CREATE_REPORT_NOTE, REPORT, REPORTS, UPDATE_REPORT } from '@/graphql/report';
|
||||
import { IReport, IReportNote, ReportStatusEnum } from '@/types/report.model';
|
||||
import { EventRouteName } from '@/router/event';
|
||||
import { ActorRouteName } from '@/router/actor';
|
||||
import { AdminRouteName } from '@/router/admin';
|
||||
import { ModerationRouteName } from '@/router/moderation';
|
||||
import { LOGGED_PERSON } from '@/graphql/actor';
|
||||
import { IPerson } from '@/types/actor';
|
||||
import { DELETE_EVENT } from '@/graphql/event';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
report: {
|
||||
query: REPORT,
|
||||
variables() {
|
||||
return {
|
||||
id: this.reportId,
|
||||
};
|
||||
},
|
||||
error({ graphQLErrors }) {
|
||||
this.errors = uniq(graphQLErrors.map(({ message }) => message));
|
||||
},
|
||||
},
|
||||
loggedPerson: {
|
||||
query: LOGGED_PERSON,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class Report extends Vue {
|
||||
@Prop({ required: true }) reportId!: number;
|
||||
report!: IReport;
|
||||
loggedPerson!: IPerson;
|
||||
errors: string[] = [];
|
||||
|
||||
ReportStatusEnum = ReportStatusEnum;
|
||||
EventRouteName = EventRouteName;
|
||||
ActorRouteName = ActorRouteName;
|
||||
AdminRouteName = AdminRouteName;
|
||||
ModerationRouteName = ModerationRouteName;
|
||||
|
||||
noteContent: string = '';
|
||||
|
||||
addNote() {
|
||||
try {
|
||||
this.$apollo.mutate<{ createReportNote: IReportNote }>({
|
||||
mutation: CREATE_REPORT_NOTE,
|
||||
variables: {
|
||||
reportId: this.report.id,
|
||||
moderatorId: this.loggedPerson.id,
|
||||
content: this.noteContent,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (data == null) return;
|
||||
const cachedData = store.readQuery<{ report: IReport }>({ query: REPORT, variables: { id: this.report.id } });
|
||||
if (cachedData == null) return;
|
||||
const { report } = cachedData;
|
||||
if (report === null) {
|
||||
console.error('Cannot update event notes cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
const note = data.createReportNote;
|
||||
note.moderator = this.loggedPerson;
|
||||
|
||||
report.notes = report.notes.concat([note]);
|
||||
|
||||
store.writeQuery({ query: REPORT, data: { report } });
|
||||
},
|
||||
});
|
||||
|
||||
this.noteContent = '';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete() {
|
||||
this.$buefy.dialog.confirm({
|
||||
title: 'Deleting event',
|
||||
message: 'Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.',
|
||||
confirmText: 'Delete Event',
|
||||
type: 'is-danger',
|
||||
hasIcon: true,
|
||||
onConfirm: () => this.deleteEvent(),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEvent() {
|
||||
if (!this.report.event || !this.report.event.id) return;
|
||||
const eventTitle = this.report.event.title;
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: DELETE_EVENT,
|
||||
variables: {
|
||||
eventId: this.report.event.id.toString(),
|
||||
actorId: this.loggedPerson.id,
|
||||
},
|
||||
});
|
||||
|
||||
this.$buefy.notification.open({
|
||||
message: this.$gettextInterpolate('Event %{eventTitle} deleted', { eventTitle }),
|
||||
type: 'is-success',
|
||||
position: 'is-bottom-right',
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateReport(status: ReportStatusEnum) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: UPDATE_REPORT,
|
||||
variables: {
|
||||
reportId: this.report.id,
|
||||
moderatorId: this.loggedPerson.id,
|
||||
status,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (data == null) return;
|
||||
const reportCachedData = store.readQuery<{ report: IReport }>({ query: REPORT, variables: { id: this.report.id } });
|
||||
if (reportCachedData == null) return;
|
||||
const { report } = reportCachedData;
|
||||
if (report === null) {
|
||||
console.error('Cannot update event notes cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
const updatedReport = data.updateReportStatus;
|
||||
report.status = updatedReport.status;
|
||||
|
||||
store.writeQuery({ query: REPORT, data: { report } });
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO make me a global function
|
||||
formatDate(value) {
|
||||
return value ? new Date(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : null;
|
||||
}
|
||||
|
||||
formatTime(value) {
|
||||
return value ? new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' }) : null;
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.container li {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
tbody td img.image, .note img.image {
|
||||
display: inline;
|
||||
height: 1.5em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.dialog .modal-card-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
90
js/src/views/Moderation/ReportList.vue
Normal file
90
js/src/views/Moderation/ReportList.vue
Normal file
@ -0,0 +1,90 @@
|
||||
import {ReportStatusEnum} from "@/types/report.model";
|
||||
<template>
|
||||
<section class="container">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
|
||||
<li class="is-active"><router-link :to="{ name: ModerationRouteName.REPORTS }" aria-current="page">Reports</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<b-field>
|
||||
<b-radio-button v-model="filterReports"
|
||||
:native-value="ReportStatusEnum.OPEN">
|
||||
Ouvert
|
||||
</b-radio-button>
|
||||
<b-radio-button v-model="filterReports"
|
||||
:native-value="ReportStatusEnum.RESOLVED">
|
||||
Résolus
|
||||
</b-radio-button>
|
||||
<b-radio-button v-model="filterReports"
|
||||
:native-value="ReportStatusEnum.CLOSED">
|
||||
Fermés
|
||||
</b-radio-button>
|
||||
</b-field>
|
||||
<ul v-if="reports.length > 0">
|
||||
<li v-for="report in reports">
|
||||
<router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: report.id } }">
|
||||
<report-card :report="report" />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else>
|
||||
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">No open reports yet</b-message>
|
||||
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">No resolved reports yet</b-message>
|
||||
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">No closed reports yet</b-message>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||
import { IReport, ReportStatusEnum } from '@/types/report.model';
|
||||
import { REPORTS } from '@/graphql/report';
|
||||
import ReportCard from '@/components/Report/ReportCard.vue';
|
||||
import { AdminRouteName } from '@/router/admin';
|
||||
import { ModerationRouteName } from '@/router/moderation';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
ReportCard,
|
||||
},
|
||||
apollo: {
|
||||
reports: {
|
||||
query: REPORTS,
|
||||
fetchPolicy: 'no-cache',
|
||||
variables() {
|
||||
return {
|
||||
status: this.filterReports,
|
||||
};
|
||||
},
|
||||
pollInterval: 120000, // 2 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class ReportList extends Vue {
|
||||
|
||||
reports?: IReport[] = [];
|
||||
AdminRouteName = AdminRouteName;
|
||||
ModerationRouteName = ModerationRouteName;
|
||||
ReportStatusEnum = ReportStatusEnum;
|
||||
filterReports: ReportStatusEnum = ReportStatusEnum.OPEN;
|
||||
|
||||
@Watch('$route.params.filter', { immediate: true })
|
||||
onRouteFilterChanged (val: string) {
|
||||
if (!val) return;
|
||||
const filter = val.toUpperCase();
|
||||
if (filter in ReportStatusEnum) {
|
||||
this.filterReports = filter as ReportStatusEnum;
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('filterReports', { immediate: true })
|
||||
async onFilterChanged (val: string) {
|
||||
await this.$router.push({ name: ModerationRouteName.REPORTS, params: { filter: val.toLowerCase() } });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.container li {
|
||||
margin: 10px auto;
|
||||
}
|
||||
</style>
|
@ -143,6 +143,7 @@ export default class Login extends Vue {
|
||||
id: data.login.user.id,
|
||||
email: this.credentials.email,
|
||||
isLoggedIn: true,
|
||||
role: data.login.user.role,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -22,7 +22,8 @@ defmodule Mobilizon.Admin do
|
||||
def list_action_logs(page \\ nil, limit \\ nil) do
|
||||
from(
|
||||
r in ActionLog,
|
||||
preload: [:actor]
|
||||
preload: [:actor],
|
||||
order_by: [desc: :id]
|
||||
)
|
||||
|> paginate(page, limit)
|
||||
|> Repo.all()
|
||||
|
@ -1,3 +1,11 @@
|
||||
import EctoEnum
|
||||
|
||||
defenum(Mobilizon.Admin.ActionLogAction, [
|
||||
"update",
|
||||
"create",
|
||||
"delete"
|
||||
])
|
||||
|
||||
defmodule Mobilizon.Admin.ActionLog do
|
||||
@moduledoc """
|
||||
ActionLog entity schema
|
||||
@ -5,11 +13,13 @@ defmodule Mobilizon.Admin.ActionLog do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Admin.ActionLogAction
|
||||
|
||||
@timestamps_opts [type: :utc_datetime]
|
||||
@required_attrs [:action, :target_type, :target_id, :changes, :actor_id]
|
||||
|
||||
schema "admin_action_logs" do
|
||||
field(:action, :string)
|
||||
field(:action, ActionLogAction)
|
||||
field(:target_type, :string)
|
||||
field(:target_id, :integer)
|
||||
field(:changes, :map)
|
||||
|
@ -54,6 +54,21 @@ defmodule Mobilizon.Application do
|
||||
],
|
||||
id: :cache_ics
|
||||
),
|
||||
worker(
|
||||
Cachex,
|
||||
[
|
||||
:statistics,
|
||||
[
|
||||
limit: 10,
|
||||
expiration:
|
||||
expiration(
|
||||
default: :timer.minutes(60),
|
||||
interval: :timer.seconds(60)
|
||||
)
|
||||
]
|
||||
],
|
||||
id: :cache_statistics
|
||||
),
|
||||
worker(
|
||||
Cachex,
|
||||
[
|
||||
|
@ -42,6 +42,7 @@ defmodule Mobilizon.Events.EventOptions do
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
@derive Jason.Encoder
|
||||
embedded_schema do
|
||||
field(:maximum_attendee_capacity, :integer)
|
||||
field(:remaining_attendee_capacity, :integer)
|
||||
|
@ -30,16 +30,28 @@ defmodule Mobilizon.Reports do
|
||||
|
||||
"""
|
||||
@spec list_reports(integer(), integer(), atom(), atom()) :: list(Report.t())
|
||||
def list_reports(page \\ nil, limit \\ nil, sort \\ :updated_at, direction \\ :asc) do
|
||||
def list_reports(
|
||||
page \\ nil,
|
||||
limit \\ nil,
|
||||
sort \\ :updated_at,
|
||||
direction \\ :desc,
|
||||
status \\ :open
|
||||
) do
|
||||
from(
|
||||
r in Report,
|
||||
preload: [:reported, :reporter, :manager, :event, :comments, :notes]
|
||||
preload: [:reported, :reporter, :manager, :event, :comments, :notes],
|
||||
where: r.status == ^status
|
||||
)
|
||||
|> paginate(page, limit)
|
||||
|> sort(sort, direction)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def count_opened_reports() do
|
||||
query = from(r in Report, where: r.status == ^:open)
|
||||
Repo.aggregate(query, :count, :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single report.
|
||||
|
||||
|
@ -7,6 +7,7 @@ defmodule Mobilizon.Reports.Note do
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Reports.Report
|
||||
|
||||
@timestamps_opts [type: :utc_datetime]
|
||||
@attrs [:content, :moderator_id, :report_id]
|
||||
|
||||
@derive {Jason.Encoder, only: [:content]}
|
||||
|
@ -17,6 +17,8 @@ defmodule Mobilizon.Reports.Report do
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Reports.Note
|
||||
|
||||
@timestamps_opts [type: :utc_datetime]
|
||||
|
||||
@derive {Jason.Encoder, only: [:status, :uri]}
|
||||
schema "reports" do
|
||||
field(:content, :string)
|
||||
@ -48,7 +50,7 @@ defmodule Mobilizon.Reports.Report do
|
||||
def changeset(report, attrs) do
|
||||
report
|
||||
|> cast(attrs, [:content, :status, :uri, :reported_id, :reporter_id, :manager_id, :event_id])
|
||||
|> validate_required([:content, :uri, :reported_id, :reporter_id])
|
||||
|> validate_required([:uri, :reported_id, :reporter_id])
|
||||
end
|
||||
|
||||
def creation_changeset(report, attrs) do
|
||||
|
@ -135,4 +135,13 @@ defmodule MobilizonWeb.API.Events do
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Trigger the deletion of an event
|
||||
|
||||
If the event is deleted by
|
||||
"""
|
||||
def delete_event(%Event{} = event, federate \\ true) do
|
||||
ActivityPub.delete(event, federate)
|
||||
end
|
||||
end
|
||||
|
@ -21,21 +21,17 @@ defmodule MobilizonWeb.API.Reports do
|
||||
def report(
|
||||
%{
|
||||
reporter_actor_id: reporter_actor_id,
|
||||
reported_actor_id: reported_actor_id,
|
||||
event_id: event_id,
|
||||
comments_ids: comments_ids,
|
||||
report_content: report_content
|
||||
reported_actor_id: reported_actor_id
|
||||
} = args
|
||||
) do
|
||||
with {:reporter, %Actor{url: reporter_url} = _reporter_actor} <-
|
||||
{:reporter, Actors.get_actor!(reporter_actor_id)},
|
||||
{:reported, %Actor{url: reported_actor_url} = reported_actor} <-
|
||||
{:reported, Actors.get_actor!(reported_actor_id)},
|
||||
{:ok, content} <- make_report_content_html(report_content),
|
||||
{:ok, event} <-
|
||||
if(event_id, do: Events.get_event(event_id), else: {:ok, nil}),
|
||||
{:ok, content} <- args |> Map.get(:content, nil) |> make_report_content_text(),
|
||||
{:ok, event} <- args |> Map.get(:event_id, nil) |> get_event(),
|
||||
{:get_report_comments, comments_urls} <-
|
||||
get_report_comments(reported_actor, comments_ids),
|
||||
get_report_comments(reported_actor, Map.get(args, :comments_ids, [])),
|
||||
{:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} <-
|
||||
{:make_activity,
|
||||
ActivityPub.flag(%{
|
||||
@ -49,6 +45,7 @@ defmodule MobilizonWeb.API.Reports do
|
||||
})} do
|
||||
{:ok, activity, report}
|
||||
else
|
||||
{:make_activity, err} -> {:error, err}
|
||||
{:error, err} -> {:error, err}
|
||||
{:actor_id, %{}} -> {:error, "Valid `actor_id` required"}
|
||||
{:reporter, nil} -> {:error, "Reporter Actor not found"}
|
||||
@ -56,6 +53,9 @@ defmodule MobilizonWeb.API.Reports do
|
||||
end
|
||||
end
|
||||
|
||||
defp get_event(nil), do: {:ok, nil}
|
||||
defp get_event(event_id), do: Events.get_event(event_id)
|
||||
|
||||
@doc """
|
||||
Update the state of a report
|
||||
"""
|
||||
|
@ -122,9 +122,9 @@ defmodule MobilizonWeb.API.Utils do
|
||||
# |> Formatter.html_escape("text/html")
|
||||
# end
|
||||
|
||||
def make_report_content_html(nil), do: {:ok, {nil, [], []}}
|
||||
def make_report_content_text(nil), do: {:ok, nil}
|
||||
|
||||
def make_report_content_html(comment) do
|
||||
def make_report_content_text(comment) do
|
||||
max_size = Mobilizon.CommonConfig.get([:instance, :max_report_comment_size], 1000)
|
||||
|
||||
if String.length(comment) <= max_size do
|
||||
|
@ -6,8 +6,8 @@
|
||||
defmodule MobilizonWeb.NodeInfoController do
|
||||
use MobilizonWeb, :controller
|
||||
|
||||
alias Mobilizon.{Events, Users}
|
||||
alias Mobilizon.CommonConfig
|
||||
alias Mobilizon.Service.Statistics
|
||||
|
||||
@instance Application.get_env(:mobilizon, :instance)
|
||||
@node_info_supported_versions ["2.0", "2.1"]
|
||||
@ -34,7 +34,7 @@ defmodule MobilizonWeb.NodeInfoController do
|
||||
response = %{
|
||||
version: version,
|
||||
software: %{
|
||||
name: "mobilizon",
|
||||
name: "Mobilizon",
|
||||
version: Keyword.get(@instance, :version)
|
||||
},
|
||||
protocols: ["activitypub"],
|
||||
@ -45,10 +45,10 @@ defmodule MobilizonWeb.NodeInfoController do
|
||||
openRegistrations: CommonConfig.registrations_open?(),
|
||||
usage: %{
|
||||
users: %{
|
||||
total: Users.count_users()
|
||||
total: Statistics.get_cached_value(:local_users)
|
||||
},
|
||||
localPosts: Events.count_local_events(),
|
||||
localComments: Events.count_local_comments()
|
||||
localPosts: Statistics.get_cached_value(:local_events),
|
||||
localComments: Statistics.get_cached_value(:local_comments)
|
||||
},
|
||||
metadata: %{
|
||||
nodeName: CommonConfig.instance_name(),
|
||||
|
@ -2,10 +2,13 @@ defmodule MobilizonWeb.Resolvers.Admin do
|
||||
@moduledoc """
|
||||
Handles the report-related GraphQL calls
|
||||
"""
|
||||
alias Mobilizon.Events
|
||||
alias Mobilizon.Users.User
|
||||
import Mobilizon.Users.Guards
|
||||
alias Mobilizon.Admin.ActionLog
|
||||
alias Mobilizon.Reports.{Report, Note}
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Service.Statistics
|
||||
|
||||
def list_action_logs(_parent, %{page: page, limit: limit}, %{
|
||||
context: %{current_user: %User{role: role}}
|
||||
@ -17,14 +20,19 @@ defmodule MobilizonWeb.Resolvers.Admin do
|
||||
target_type: target_type,
|
||||
action: action,
|
||||
actor: actor,
|
||||
id: id
|
||||
id: id,
|
||||
inserted_at: inserted_at
|
||||
} = action_log ->
|
||||
transform_action_log(target_type, action, action_log)
|
||||
|> Map.merge(%{
|
||||
actor: actor,
|
||||
id: id
|
||||
})
|
||||
with data when is_map(data) <-
|
||||
transform_action_log(String.to_existing_atom(target_type), action, action_log) do
|
||||
Map.merge(data, %{
|
||||
actor: actor,
|
||||
id: id,
|
||||
inserted_at: inserted_at
|
||||
})
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(& &1)
|
||||
|
||||
{:ok, action_logs}
|
||||
end
|
||||
@ -35,38 +43,87 @@ defmodule MobilizonWeb.Resolvers.Admin do
|
||||
end
|
||||
|
||||
defp transform_action_log(
|
||||
"Elixir.Mobilizon.Reports.Report",
|
||||
"update",
|
||||
Report,
|
||||
:update,
|
||||
%ActionLog{} = action_log
|
||||
) do
|
||||
with %Report{status: status} = report <- Mobilizon.Reports.get_report(action_log.target_id) do
|
||||
with %Report{} = report <- Mobilizon.Reports.get_report(action_log.target_id) do
|
||||
action =
|
||||
case action_log do
|
||||
%ActionLog{changes: %{"status" => "closed"}} -> :report_update_closed
|
||||
%ActionLog{changes: %{"status" => "open"}} -> :report_update_opened
|
||||
%ActionLog{changes: %{"status" => "resolved"}} -> :report_update_resolved
|
||||
end
|
||||
|
||||
%{
|
||||
action: "report_update_" <> to_string(status),
|
||||
action: action,
|
||||
object: report
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp transform_action_log("Elixir.Mobilizon.Reports.Note", "create", %ActionLog{
|
||||
defp transform_action_log(Note, :create, %ActionLog{
|
||||
changes: changes
|
||||
}) do
|
||||
%{
|
||||
action: "note_creation",
|
||||
action: :note_creation,
|
||||
object: convert_changes_to_struct(Note, changes)
|
||||
}
|
||||
end
|
||||
|
||||
defp transform_action_log("Elixir.Mobilizon.Reports.Note", "delete", %ActionLog{
|
||||
defp transform_action_log(Note, :delete, %ActionLog{
|
||||
changes: changes
|
||||
}) do
|
||||
%{
|
||||
action: "note_deletion",
|
||||
action: :note_deletion,
|
||||
object: convert_changes_to_struct(Note, changes)
|
||||
}
|
||||
end
|
||||
|
||||
defp transform_action_log(Event, :delete, %ActionLog{
|
||||
changes: changes
|
||||
}) do
|
||||
%{
|
||||
action: :event_deletion,
|
||||
object: convert_changes_to_struct(Event, changes)
|
||||
}
|
||||
end
|
||||
|
||||
# Changes are stored as %{"key" => "value"} so we need to convert them back as struct
|
||||
defp convert_changes_to_struct(struct, %{"report_id" => _report_id} = changes) do
|
||||
with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}),
|
||||
data <- Map.put(data, :report, Mobilizon.Reports.get_report(data.report_id)) do
|
||||
struct(struct, data)
|
||||
end
|
||||
end
|
||||
|
||||
defp convert_changes_to_struct(struct, changes) do
|
||||
struct(struct, for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}))
|
||||
with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}) do
|
||||
struct(struct, data)
|
||||
end
|
||||
end
|
||||
|
||||
def get_dashboard(_parent, _args, %{
|
||||
context: %{current_user: %User{role: role}}
|
||||
})
|
||||
when is_admin(role) do
|
||||
last_public_event_published =
|
||||
case Events.list_events(1, 1, :inserted_at, :desc) do
|
||||
[event | _] -> event
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
number_of_users: Statistics.get_cached_value(:local_users),
|
||||
number_of_events: Statistics.get_cached_value(:local_events),
|
||||
number_of_comments: Statistics.get_cached_value(:local_comments),
|
||||
number_of_reports: Mobilizon.Reports.count_opened_reports(),
|
||||
last_public_event_published: last_public_event_published
|
||||
}}
|
||||
end
|
||||
|
||||
def get_dashboard(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in and an administrator to access dashboard statistics"}
|
||||
end
|
||||
end
|
||||
|
@ -9,7 +9,10 @@ defmodule MobilizonWeb.Resolvers.Event do
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.Media.Picture
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Actors
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias MobilizonWeb.Resolvers.Person
|
||||
import Mobilizon.Service.Admin.ActionLogService
|
||||
|
||||
# We limit the max number of events that can be retrieved
|
||||
@event_max_limit 100
|
||||
@ -328,28 +331,43 @@ defmodule MobilizonWeb.Resolvers.Event do
|
||||
%{event_id: event_id, actor_id: actor_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: user
|
||||
current_user: %User{role: role} = user
|
||||
}
|
||||
}
|
||||
) do
|
||||
with {:ok, %Event{} = event} <- Mobilizon.Events.get_event(event_id),
|
||||
{:is_owned, true, _} <- User.owns_actor(user, actor_id),
|
||||
{:event_can_be_managed, true} <- Event.can_event_be_managed_by(event, actor_id),
|
||||
event <- Mobilizon.Events.delete_event!(event) do
|
||||
{:ok, %{id: event.id}}
|
||||
with {:ok, %Event{local: is_local} = event} <- Mobilizon.Events.get_event_full(event_id),
|
||||
{actor_id, ""} <- Integer.parse(actor_id),
|
||||
{:is_owned, true, _} <- User.owns_actor(user, actor_id) do
|
||||
cond do
|
||||
Event.can_event_be_managed_by(event, actor_id) == {:event_can_be_managed, true} ->
|
||||
do_delete_event(event)
|
||||
|
||||
role in [:moderator, :administrator] ->
|
||||
with {:ok, res} <- do_delete_event(event, !is_local),
|
||||
%Actor{} = actor <- Actors.get_actor(actor_id) do
|
||||
log_action(actor, "delete", event)
|
||||
{:ok, res}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:error, "You cannot delete this event"}
|
||||
end
|
||||
else
|
||||
{:error, :event_not_found} ->
|
||||
{:error, "Event not found"}
|
||||
|
||||
{:is_owned, false} ->
|
||||
{:error, "Actor id is not owned by authenticated user"}
|
||||
|
||||
{:event_can_be_managed, false} ->
|
||||
{:error, "You cannot delete this event"}
|
||||
end
|
||||
end
|
||||
|
||||
def delete_event(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in to delete an event"}
|
||||
end
|
||||
|
||||
defp do_delete_event(event, federate \\ true) when is_boolean(federate) do
|
||||
with {:ok, _activity, event} <- MobilizonWeb.API.Events.delete_event(event) do
|
||||
{:ok, %{id: event.id}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -89,7 +89,9 @@ defmodule MobilizonWeb.Resolvers.Group do
|
||||
}
|
||||
}
|
||||
) do
|
||||
with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
with {actor_id, ""} <- Integer.parse(actor_id),
|
||||
{group_id, ""} <- Integer.parse(group_id),
|
||||
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
{:is_owned, true, _} <- User.owns_actor(user, actor_id),
|
||||
{:ok, %Member{} = member} <- Member.get_member(actor_id, group.id),
|
||||
{:is_admin, true} <- Member.is_administrator(member),
|
||||
@ -126,7 +128,9 @@ defmodule MobilizonWeb.Resolvers.Group do
|
||||
}
|
||||
}
|
||||
) do
|
||||
with {:is_owned, true, actor} <- User.owns_actor(user, actor_id),
|
||||
with {actor_id, ""} <- Integer.parse(actor_id),
|
||||
{group_id, ""} <- Integer.parse(group_id),
|
||||
{:is_owned, true, actor} <- User.owns_actor(user, actor_id),
|
||||
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
{:error, :member_not_found} <- Member.get_member(actor.id, group.id),
|
||||
{:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)},
|
||||
@ -180,7 +184,9 @@ defmodule MobilizonWeb.Resolvers.Group do
|
||||
}
|
||||
}
|
||||
) do
|
||||
with {:is_owned, true, actor} <- User.owns_actor(user, actor_id),
|
||||
with {actor_id, ""} <- Integer.parse(actor_id),
|
||||
{group_id, ""} <- Integer.parse(group_id),
|
||||
{:is_owned, true, actor} <- User.owns_actor(user, actor_id),
|
||||
{:ok, %Member{} = member} <- Member.get_member(actor.id, group_id),
|
||||
{:only_administrator, false} <-
|
||||
{:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)},
|
||||
|
@ -10,11 +10,11 @@ defmodule MobilizonWeb.Resolvers.Report do
|
||||
alias MobilizonWeb.API.Reports, as: ReportsAPI
|
||||
import Mobilizon.Users.Guards
|
||||
|
||||
def list_reports(_parent, %{page: page, limit: limit}, %{
|
||||
def list_reports(_parent, %{page: page, limit: limit, status: status}, %{
|
||||
context: %{current_user: %User{role: role}}
|
||||
})
|
||||
when is_moderator(role) do
|
||||
{:ok, Mobilizon.Reports.list_reports(page, limit)}
|
||||
{:ok, Mobilizon.Reports.list_reports(page, limit, :updated_at, :desc, status)}
|
||||
end
|
||||
|
||||
def list_reports(_parent, _args, _resolution) do
|
||||
@ -25,7 +25,13 @@ defmodule MobilizonWeb.Resolvers.Report do
|
||||
context: %{current_user: %User{role: role}}
|
||||
})
|
||||
when is_moderator(role) do
|
||||
{:ok, Mobilizon.Reports.get_report(id)}
|
||||
case Mobilizon.Reports.get_report(id) do
|
||||
%Report{} = report ->
|
||||
{:ok, report}
|
||||
|
||||
nil ->
|
||||
{:error, "Report not found"}
|
||||
end
|
||||
end
|
||||
|
||||
def get_report(_parent, _args, _resolution) do
|
||||
|
@ -78,6 +78,9 @@ defmodule MobilizonWeb.Router do
|
||||
get("/events/create", PageController, :index)
|
||||
get("/events/list", PageController, :index)
|
||||
get("/events/:uuid/edit", PageController, :index)
|
||||
|
||||
# This is a hack to ease link generation into emails
|
||||
get("/moderation/reports/:id", PageController, :index, as: "moderation_report")
|
||||
end
|
||||
|
||||
scope "/", MobilizonWeb do
|
||||
|
@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do
|
||||
"""
|
||||
use Absinthe.Schema
|
||||
|
||||
alias Mobilizon.{Actors, Events, Users, Addresses, Media}
|
||||
alias Mobilizon.{Actors, Events, Users, Addresses, Media, Reports}
|
||||
alias Mobilizon.Actors.{Actor, Follower, Member}
|
||||
alias Mobilizon.Events.{Event, Comment, Participant}
|
||||
|
||||
@ -26,7 +26,7 @@ defmodule MobilizonWeb.Schema do
|
||||
|
||||
@desc "A struct containing the id of the deleted object"
|
||||
object :deleted_object do
|
||||
field(:id, :integer)
|
||||
field(:id, :id)
|
||||
end
|
||||
|
||||
@desc "A JWT and the associated user ID"
|
||||
@ -44,7 +44,7 @@ defmodule MobilizonWeb.Schema do
|
||||
Represents a notification for an user
|
||||
"""
|
||||
object :notification do
|
||||
field(:id, :integer, description: "The notification ID")
|
||||
field(:id, :id, description: "The notification ID")
|
||||
field(:user, :user, description: "The user to transmit the notification to")
|
||||
field(:actor, :actor, description: "The notification target profile")
|
||||
|
||||
@ -94,6 +94,7 @@ defmodule MobilizonWeb.Schema do
|
||||
|> Dataloader.add_source(Events, Events.data())
|
||||
|> Dataloader.add_source(Addresses, Addresses.data())
|
||||
|> Dataloader.add_source(Media, Media.data())
|
||||
|> Dataloader.add_source(Reports, Reports.data())
|
||||
|
||||
Map.put(ctx, :loader, loader)
|
||||
end
|
||||
|
@ -13,7 +13,7 @@ defmodule MobilizonWeb.Schema.ActorInterface do
|
||||
|
||||
@desc "An ActivityPub actor"
|
||||
interface :actor do
|
||||
field(:id, :integer, description: "Internal ID for this actor")
|
||||
field(:id, :id, description: "Internal ID for this actor")
|
||||
field(:url, :string, description: "The ActivityPub actor's URL")
|
||||
field(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
|
||||
field(:name, :string, description: "The actor's displayed name")
|
||||
|
@ -14,7 +14,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
|
||||
object :group do
|
||||
interfaces([:actor])
|
||||
|
||||
field(:id, :integer, 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(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
|
||||
field(:name, :string, description: "The actor's displayed name")
|
||||
@ -96,9 +96,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
|
||||
field :create_group, :group do
|
||||
arg(:preferred_username, non_null(:string), description: "The name for the group")
|
||||
|
||||
arg(:creator_actor_id, non_null(:integer),
|
||||
description: "The identity that creates the group"
|
||||
)
|
||||
arg(:creator_actor_id, non_null(:id), description: "The identity that creates the group")
|
||||
|
||||
arg(:name, :string, description: "The displayed name for the group")
|
||||
arg(:summary, :string, description: "The summary for the group", default_value: "")
|
||||
@ -118,8 +116,8 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
|
||||
|
||||
@desc "Delete a group"
|
||||
field :delete_group, :deleted_object do
|
||||
arg(:group_id, non_null(:integer))
|
||||
arg(:actor_id, non_null(:integer))
|
||||
arg(:group_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
|
||||
resolve(&Group.delete_group/3)
|
||||
end
|
||||
|
@ -24,16 +24,16 @@ defmodule MobilizonWeb.Schema.Actors.MemberType do
|
||||
object :member_mutations do
|
||||
@desc "Join a group"
|
||||
field :join_group, :member do
|
||||
arg(:group_id, non_null(:integer))
|
||||
arg(:actor_id, non_null(:integer))
|
||||
arg(:group_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
|
||||
resolve(&Resolvers.Group.join_group/3)
|
||||
end
|
||||
|
||||
@desc "Leave an event"
|
||||
field :leave_group, :deleted_member do
|
||||
arg(:group_id, non_null(:integer))
|
||||
arg(:actor_id, non_null(:integer))
|
||||
arg(:group_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
|
||||
resolve(&Resolvers.Group.leave_group/3)
|
||||
end
|
||||
|
@ -15,7 +15,7 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
|
||||
"""
|
||||
object :person do
|
||||
interfaces([:actor])
|
||||
field(:id, :integer, description: "Internal ID for this person")
|
||||
field(:id, :id, description: "Internal ID for this person")
|
||||
field(:user, :user, description: "The user this actor is associated to")
|
||||
|
||||
field(:member_of, list_of(:member), description: "The list of groups this person is member of")
|
||||
|
@ -15,7 +15,7 @@ defmodule MobilizonWeb.Schema.AddressType do
|
||||
field(:country, :string)
|
||||
field(:description, :string)
|
||||
field(:url, :string)
|
||||
field(:id, :integer)
|
||||
field(:id, :id)
|
||||
field(:origin_id, :string)
|
||||
end
|
||||
|
||||
@ -40,7 +40,7 @@ defmodule MobilizonWeb.Schema.AddressType do
|
||||
field(:country, :string)
|
||||
field(:description, :string)
|
||||
field(:url, :string)
|
||||
field(:id, :integer)
|
||||
field(:id, :id)
|
||||
field(:origin_id, :string)
|
||||
end
|
||||
|
||||
|
@ -5,13 +5,25 @@ defmodule MobilizonWeb.Schema.AdminType do
|
||||
use Absinthe.Schema.Notation
|
||||
alias MobilizonWeb.Resolvers.Admin
|
||||
alias Mobilizon.Reports.{Report, Note}
|
||||
alias Mobilizon.Events.Event
|
||||
|
||||
@desc "An action log"
|
||||
object :action_log do
|
||||
field(:id, :id, description: "Internal ID for this comment")
|
||||
field(:actor, :actor, description: "The actor that acted")
|
||||
field(:object, :action_log_object, description: "The object that was acted upon")
|
||||
field(:action, :string, description: "The action that was done")
|
||||
field(:action, :action_log_action, description: "The action that was done")
|
||||
field(:inserted_at, :datetime, description: "The time when the action was performed")
|
||||
end
|
||||
|
||||
enum :action_log_action do
|
||||
value(:report_update_closed)
|
||||
value(:report_update_opened)
|
||||
value(:report_update_resolved)
|
||||
value(:note_creation)
|
||||
value(:note_deletion)
|
||||
value(:event_deletion)
|
||||
value(:event_update)
|
||||
end
|
||||
|
||||
@desc "The objects that can be in an action log"
|
||||
@ -25,11 +37,22 @@ defmodule MobilizonWeb.Schema.AdminType do
|
||||
%Note{}, _ ->
|
||||
:report_note
|
||||
|
||||
%Event{}, _ ->
|
||||
:event
|
||||
|
||||
_, _ ->
|
||||
nil
|
||||
end)
|
||||
end
|
||||
|
||||
object :dashboard do
|
||||
field(:last_public_event_published, :event, description: "Last public event publish")
|
||||
field(:number_of_users, :integer, description: "The number of local users")
|
||||
field(:number_of_events, :integer, description: "The number of local events")
|
||||
field(:number_of_comments, :integer, description: "The number of local comments")
|
||||
field(:number_of_reports, :integer, description: "The number of current opened reports")
|
||||
end
|
||||
|
||||
object :admin_queries do
|
||||
@desc "Get the list of action logs"
|
||||
field :action_logs, type: list_of(:action_log) do
|
||||
@ -37,5 +60,9 @@ defmodule MobilizonWeb.Schema.AdminType do
|
||||
arg(:limit, :integer, default_value: 10)
|
||||
resolve(&Admin.list_action_logs/3)
|
||||
end
|
||||
|
||||
field :dashboard, type: :dashboard do
|
||||
resolve(&Admin.get_dashboard/3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -12,7 +12,8 @@ defmodule MobilizonWeb.Schema.EventType do
|
||||
|
||||
@desc "An event"
|
||||
object :event do
|
||||
field(:id, :integer, description: "Internal ID for this event")
|
||||
interfaces([:action_log_object])
|
||||
field(:id, :id, description: "Internal ID for this event")
|
||||
field(:uuid, :uuid, description: "The Event UUID")
|
||||
field(:url, :string, description: "The ActivityPub Event URL")
|
||||
field(:local, :boolean, description: "Whether the event is local or not")
|
||||
@ -261,8 +262,8 @@ defmodule MobilizonWeb.Schema.EventType do
|
||||
|
||||
@desc "Delete an event"
|
||||
field :delete_event, :deleted_object do
|
||||
arg(:event_id, non_null(:integer))
|
||||
arg(:actor_id, non_null(:integer))
|
||||
arg(:event_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
|
||||
resolve(&Event.delete_event/3)
|
||||
end
|
||||
|
@ -36,7 +36,7 @@ defmodule MobilizonWeb.Schema.Events.FeedTokenType do
|
||||
object :feed_token_mutations do
|
||||
@desc "Create a Feed Token"
|
||||
field :create_feed_token, :feed_token do
|
||||
arg(:actor_id, :integer)
|
||||
arg(:actor_id, :id)
|
||||
|
||||
resolve(&Resolvers.FeedToken.create_feed_token/3)
|
||||
end
|
||||
|
@ -46,16 +46,16 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
|
||||
object :participant_mutations do
|
||||
@desc "Join an event"
|
||||
field :join_event, :participant do
|
||||
arg(:event_id, non_null(:integer))
|
||||
arg(:actor_id, non_null(:integer))
|
||||
arg(:event_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
|
||||
resolve(&Resolvers.Event.actor_join_event/3)
|
||||
end
|
||||
|
||||
@desc "Leave an event"
|
||||
field :leave_event, :deleted_participant do
|
||||
arg(:event_id, non_null(:integer))
|
||||
arg(:actor_id, non_null(:integer))
|
||||
arg(:event_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
|
||||
resolve(&Resolvers.Event.actor_leave_event/3)
|
||||
end
|
||||
|
@ -3,6 +3,8 @@ defmodule MobilizonWeb.Schema.ReportType do
|
||||
Schema representation for User
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
||||
alias Mobilizon.Reports
|
||||
|
||||
alias MobilizonWeb.Resolvers.Report
|
||||
|
||||
@ -17,6 +19,14 @@ defmodule MobilizonWeb.Schema.ReportType do
|
||||
field(:reporter, :actor, description: "The actor that created the report")
|
||||
field(:event, :event, description: "The event that is being reported")
|
||||
field(:comments, list_of(:comment), description: "The comments that are reported")
|
||||
|
||||
field(:notes, list_of(:report_note),
|
||||
description: "The notes made on the event",
|
||||
resolve: dataloader(Reports)
|
||||
)
|
||||
|
||||
field(:inserted_at, :datetime, description: "When the report was created")
|
||||
field(:updated_at, :datetime, description: "When the report was updated")
|
||||
end
|
||||
|
||||
@desc "A report note object"
|
||||
@ -24,8 +34,14 @@ defmodule MobilizonWeb.Schema.ReportType do
|
||||
interfaces([:action_log_object])
|
||||
field(:id, :id, description: "The internal ID of the report note")
|
||||
field(:content, :string, description: "The content of the note")
|
||||
field(:moderator, :actor, description: "The moderator who added the note")
|
||||
|
||||
field(:moderator, :actor,
|
||||
description: "The moderator who added the note",
|
||||
resolve: dataloader(Reports)
|
||||
)
|
||||
|
||||
field(:report, :report, description: "The report on which this note is added")
|
||||
field(:inserted_at, :datetime, description: "When the report note was created")
|
||||
end
|
||||
|
||||
@desc "The list of possible statuses for a report object"
|
||||
@ -40,6 +56,7 @@ defmodule MobilizonWeb.Schema.ReportType do
|
||||
field :reports, list_of(:report) do
|
||||
arg(:page, :integer, default_value: 1)
|
||||
arg(:limit, :integer, default_value: 10)
|
||||
arg(:status, :report_status, default_value: :open)
|
||||
resolve(&Report.list_reports/3)
|
||||
end
|
||||
|
||||
@ -53,7 +70,7 @@ defmodule MobilizonWeb.Schema.ReportType do
|
||||
object :report_mutations do
|
||||
@desc "Create a report"
|
||||
field :create_report, type: :report do
|
||||
arg(:report_content, :string)
|
||||
arg(:content, :string)
|
||||
arg(:reporter_actor_id, non_null(:id))
|
||||
arg(:reported_actor_id, non_null(:id))
|
||||
arg(:event_id, :id, default_value: nil)
|
||||
|
@ -43,6 +43,14 @@ defmodule MobilizonWeb.Schema.UserType do
|
||||
resolve: dataloader(Events),
|
||||
description: "A list of the feed tokens for this user"
|
||||
)
|
||||
|
||||
field(:role, :user_role, description: "The role for the user")
|
||||
end
|
||||
|
||||
enum :user_role do
|
||||
value(:administrator)
|
||||
value(:moderator)
|
||||
value(:user)
|
||||
end
|
||||
|
||||
@desc "Token"
|
||||
|
@ -1,15 +1,15 @@
|
||||
<h1><%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %></h1>
|
||||
<h1><%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter.preferred_username, instance: @instance %></h1>
|
||||
|
||||
<% if @report.event do %>
|
||||
<p><%= gettext "Event: %{event}", event: @report.event %></p>
|
||||
<p><%= gettext "Event: %{event}", event: @report.event.title %></p>
|
||||
<% end %>
|
||||
|
||||
<%= for comment <- @report.comments do %>
|
||||
<p><%= gettext "Comment: %{comment}", comment: comment %></p>
|
||||
<% end %>
|
||||
|
||||
<% if @content do %>
|
||||
<% if @report.content do %>
|
||||
<p><%= gettext "Reason: %{content}", event: @report.content %></p>
|
||||
<% end %>
|
||||
|
||||
<p><%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %></p>
|
||||
<p><%= link "View the report", to: moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id), target: "_blank" %></p>
|
@ -1,19 +1,19 @@
|
||||
<%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %>
|
||||
<%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter.preferred_username, instance: @instance %>
|
||||
|
||||
--
|
||||
|
||||
<% if @report.event do %>
|
||||
<%= gettext "Event: %{event}", event: @report.event %>
|
||||
<%= gettext "Event: %{event}", event: @report.event.title %>
|
||||
<% end %>
|
||||
|
||||
<%= for comment <- @report.comments do %>
|
||||
<%= gettext "Comment: %{comment}", comment: comment %>
|
||||
<%= gettext "Comment: %{comment}", comment: comment.text %>
|
||||
<% end %>
|
||||
|
||||
<% if @content do %>
|
||||
<% if @report.content do %>
|
||||
<%= gettext "Reason: %{content}", event: @report.content %>
|
||||
<% end %>
|
||||
|
||||
<%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %>
|
||||
View the report: <%= moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id) %>
|
||||
|
||||
|
||||
|
@ -335,9 +335,8 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||
|
||||
with {:ok, _} <- Events.delete_event(event),
|
||||
{:ok, activity} <- create_activity(data, local),
|
||||
{:ok, object} <- insert_full_object(data),
|
||||
:ok <- maybe_federate(activity) do
|
||||
{:ok, activity, object}
|
||||
{:ok, activity, event}
|
||||
end
|
||||
end
|
||||
|
||||
@ -521,7 +520,8 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||
|
||||
public = is_public?(activity)
|
||||
|
||||
if public && Mobilizon.CommonConfig.get([:instance, :allow_relay]) do
|
||||
if public && is_delete_activity?(activity) == false &&
|
||||
Mobilizon.CommonConfig.get([:instance, :allow_relay]) do
|
||||
Logger.info(fn -> "Relaying #{activity.data["id"]} out" end)
|
||||
Mobilizon.Service.ActivityPub.Relay.publish(activity)
|
||||
end
|
||||
@ -552,6 +552,9 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||
end)
|
||||
end
|
||||
|
||||
defp is_delete_activity?(%Activity{data: %{"type" => "Delete"}}), do: true
|
||||
defp is_delete_activity?(_), do: false
|
||||
|
||||
@doc """
|
||||
Publish an activity to a specific inbox
|
||||
"""
|
||||
|
@ -164,14 +164,14 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
|
||||
{:ok, %Report{} = report} <- Reports.create_report(data) do
|
||||
Enum.each(Users.list_moderators(), fn moderator ->
|
||||
moderator
|
||||
|> Mobilizon.Email.Admin.report(moderator, report)
|
||||
|> Mobilizon.Email.Admin.report(report)
|
||||
|> Mobilizon.Mailer.deliver_later()
|
||||
end)
|
||||
|
||||
{:ok, report}
|
||||
else
|
||||
err ->
|
||||
Logger.error("Error while inserting a remote comment inside database")
|
||||
Logger.error("Error while inserting report inside database")
|
||||
Logger.debug(inspect(err))
|
||||
{:error, err}
|
||||
end
|
||||
|
@ -22,9 +22,19 @@ defmodule Mobilizon.Service.Admin.ActionLogService do
|
||||
"target_type" => to_string(target.__struct__),
|
||||
"target_id" => target.id,
|
||||
"action" => action,
|
||||
"changes" => Map.from_struct(target) |> Map.take([:status, :uri, :content])
|
||||
"changes" => stringify_struct(target)
|
||||
}) do
|
||||
{:ok, create_action_log}
|
||||
end
|
||||
end
|
||||
|
||||
defp stringify_struct(%_{} = struct) do
|
||||
association_fields = struct.__struct__.__schema__(:associations)
|
||||
|
||||
struct
|
||||
|> Map.from_struct()
|
||||
|> Map.drop(association_fields ++ [:__meta__])
|
||||
end
|
||||
|
||||
defp stringify_struct(struct), do: struct
|
||||
end
|
||||
|
31
lib/service/statistics.ex
Normal file
31
lib/service/statistics.ex
Normal file
@ -0,0 +1,31 @@
|
||||
defmodule Mobilizon.Service.Statistics do
|
||||
@moduledoc """
|
||||
A module that provides cached statistics
|
||||
"""
|
||||
alias Mobilizon.Events
|
||||
alias Mobilizon.Users
|
||||
|
||||
def get_cached_value(key) do
|
||||
case Cachex.fetch(:statistics, key, fn key ->
|
||||
case create_cache(key) do
|
||||
value when not is_nil(value) -> {:commit, value}
|
||||
err -> {:ignore, err}
|
||||
end
|
||||
end) do
|
||||
{status, value} when status in [:ok, :commit] -> value
|
||||
_err -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp create_cache(:local_users) do
|
||||
Users.count_users()
|
||||
end
|
||||
|
||||
defp create_cache(:local_events) do
|
||||
Events.count_local_events()
|
||||
end
|
||||
|
||||
defp create_cache(:local_comments) do
|
||||
Events.count_local_comments()
|
||||
end
|
||||
end
|
@ -1,5 +1,5 @@
|
||||
# source: http://localhost:4000/api
|
||||
# timestamp: Mon Sep 09 2019 11:37:31 GMT+0200 (heure d’été d’Europe centrale)
|
||||
# timestamp: Mon Sep 09 2019 20:33:17 GMT+0200 (GMT+02:00)
|
||||
|
||||
schema {
|
||||
query: RootQueryType
|
||||
@ -9,7 +9,7 @@ schema {
|
||||
"""An action log"""
|
||||
type ActionLog {
|
||||
"""The action that was done"""
|
||||
action: String
|
||||
action: ActionLogAction
|
||||
|
||||
"""The actor that acted"""
|
||||
actor: Actor
|
||||
@ -17,10 +17,23 @@ type ActionLog {
|
||||
"""Internal ID for this comment"""
|
||||
id: ID
|
||||
|
||||
"""The time when the action was performed"""
|
||||
insertedAt: DateTime
|
||||
|
||||
"""The object that was acted upon"""
|
||||
object: ActionLogObject
|
||||
}
|
||||
|
||||
enum ActionLogAction {
|
||||
EVENT_DELETION
|
||||
EVENT_UPDATE
|
||||
NOTE_CREATION
|
||||
NOTE_DELETION
|
||||
REPORT_UPDATE_CLOSED
|
||||
REPORT_UPDATE_OPENED
|
||||
REPORT_UPDATE_RESOLVED
|
||||
}
|
||||
|
||||
"""The objects that can be in an action log"""
|
||||
interface ActionLogObject {
|
||||
"""Internal ID for this object"""
|
||||
@ -51,7 +64,7 @@ interface Actor {
|
||||
followingCount: Int
|
||||
|
||||
"""Internal ID for this actor"""
|
||||
id: Int
|
||||
id: ID
|
||||
|
||||
"""The actors RSA Keys"""
|
||||
keys: String
|
||||
@ -111,7 +124,7 @@ type Address {
|
||||
|
||||
"""The geocoordinates for the point where this address is"""
|
||||
geom: Point
|
||||
id: Int
|
||||
id: ID
|
||||
|
||||
"""The address's locality"""
|
||||
locality: String
|
||||
@ -133,7 +146,7 @@ input AddressInput {
|
||||
|
||||
"""The geocoordinates for the point where this address is"""
|
||||
geom: Point
|
||||
id: Int
|
||||
id: ID
|
||||
|
||||
"""The address's locality"""
|
||||
locality: String
|
||||
@ -185,6 +198,23 @@ type Config {
|
||||
registrationsOpen: Boolean
|
||||
}
|
||||
|
||||
type Dashboard {
|
||||
"""Last public event publish"""
|
||||
lastPublicEventPublished: Event
|
||||
|
||||
"""The number of local comments"""
|
||||
numberOfComments: Int
|
||||
|
||||
"""The number of local events"""
|
||||
numberOfEvents: Int
|
||||
|
||||
"""The number of current opened reports"""
|
||||
numberOfReports: Int
|
||||
|
||||
"""The number of local users"""
|
||||
numberOfUsers: Int
|
||||
}
|
||||
|
||||
"""
|
||||
The `DateTime` scalar type represents a date and time in the UTC
|
||||
timezone. The DateTime appears in a JSON response as an ISO8601 formatted
|
||||
@ -207,7 +237,7 @@ type DeletedMember {
|
||||
|
||||
"""A struct containing the id of the deleted object"""
|
||||
type DeletedObject {
|
||||
id: Int
|
||||
id: ID
|
||||
}
|
||||
|
||||
"""Represents a deleted participant"""
|
||||
@ -217,7 +247,7 @@ type DeletedParticipant {
|
||||
}
|
||||
|
||||
"""An event"""
|
||||
type Event {
|
||||
type Event implements ActionLogObject {
|
||||
"""Who the event is attributed to (often a group)"""
|
||||
attributedTo: Actor
|
||||
|
||||
@ -237,7 +267,7 @@ type Event {
|
||||
endsOn: DateTime
|
||||
|
||||
"""Internal ID for this event"""
|
||||
id: Int
|
||||
id: ID
|
||||
|
||||
"""Whether the event is local or not"""
|
||||
local: Boolean
|
||||
@ -501,7 +531,7 @@ type Group implements Actor {
|
||||
followingCount: Int
|
||||
|
||||
"""Internal ID for this group"""
|
||||
id: Int
|
||||
id: ID
|
||||
|
||||
"""The actors RSA Keys"""
|
||||
keys: String
|
||||
@ -651,7 +681,7 @@ type Person implements Actor {
|
||||
goingToEvents: [Event]
|
||||
|
||||
"""Internal ID for this person"""
|
||||
id: Int
|
||||
id: ID
|
||||
|
||||
"""The actors RSA Keys"""
|
||||
keys: String
|
||||
@ -763,6 +793,12 @@ type Report implements ActionLogObject {
|
||||
"""The internal ID of the report"""
|
||||
id: ID
|
||||
|
||||
"""When the report was created"""
|
||||
insertedAt: DateTime
|
||||
|
||||
"""The notes made on the event"""
|
||||
notes: [ReportNote]
|
||||
|
||||
"""The actor that is being reported"""
|
||||
reported: Actor
|
||||
|
||||
@ -772,6 +808,9 @@ type Report implements ActionLogObject {
|
||||
"""Whether the report is still active"""
|
||||
status: ReportStatus
|
||||
|
||||
"""When the report was updated"""
|
||||
updatedAt: DateTime
|
||||
|
||||
"""The URI of the report"""
|
||||
uri: String
|
||||
}
|
||||
@ -784,6 +823,9 @@ type ReportNote implements ActionLogObject {
|
||||
"""The internal ID of the report note"""
|
||||
id: ID
|
||||
|
||||
"""When the report note was created"""
|
||||
insertedAt: DateTime
|
||||
|
||||
"""The moderator who added the note"""
|
||||
moderator: Actor
|
||||
|
||||
@ -827,7 +869,7 @@ type RootMutationType {
|
||||
"""
|
||||
picture: PictureInput
|
||||
publishAt: DateTime
|
||||
status: Int
|
||||
status: EventStatus
|
||||
|
||||
"""The list of tags associated to the event"""
|
||||
tags: [String] = [""]
|
||||
@ -836,7 +878,7 @@ type RootMutationType {
|
||||
): Event
|
||||
|
||||
"""Create a Feed Token"""
|
||||
createFeedToken(actorId: Int): FeedToken
|
||||
createFeedToken(actorId: ID): FeedToken
|
||||
|
||||
"""Create a group"""
|
||||
createGroup(
|
||||
@ -851,7 +893,7 @@ type RootMutationType {
|
||||
banner: PictureInput
|
||||
|
||||
"""The identity that creates the group"""
|
||||
creatorActorId: Int!
|
||||
creatorActorId: ID!
|
||||
|
||||
"""The displayed name for the group"""
|
||||
name: String
|
||||
@ -884,7 +926,7 @@ type RootMutationType {
|
||||
): Person
|
||||
|
||||
"""Create a report"""
|
||||
createReport(commentsIds: [ID] = [""], eventId: ID, reportContent: String, reportedActorId: ID!, reporterActorId: ID!): Report
|
||||
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, reportedActorId: ID!, reporterActorId: ID!): Report
|
||||
|
||||
"""Create a note on a report"""
|
||||
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
|
||||
@ -893,29 +935,29 @@ type RootMutationType {
|
||||
createUser(email: String!, password: String!): User
|
||||
|
||||
"""Delete an event"""
|
||||
deleteEvent(actorId: Int!, eventId: Int!): DeletedObject
|
||||
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
|
||||
|
||||
"""Delete a feed token"""
|
||||
deleteFeedToken(token: String!): DeletedFeedToken
|
||||
|
||||
"""Delete a group"""
|
||||
deleteGroup(actorId: Int!, groupId: Int!): DeletedObject
|
||||
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
|
||||
|
||||
"""Delete an identity"""
|
||||
deletePerson(preferredUsername: String!): Person
|
||||
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
|
||||
|
||||
"""Join an event"""
|
||||
joinEvent(actorId: Int!, eventId: Int!): Participant
|
||||
joinEvent(actorId: ID!, eventId: ID!): Participant
|
||||
|
||||
"""Join a group"""
|
||||
joinGroup(actorId: Int!, groupId: Int!): Member
|
||||
joinGroup(actorId: ID!, groupId: ID!): Member
|
||||
|
||||
"""Leave an event"""
|
||||
leaveEvent(actorId: Int!, eventId: Int!): DeletedParticipant
|
||||
leaveEvent(actorId: ID!, eventId: ID!): DeletedParticipant
|
||||
|
||||
"""Leave an event"""
|
||||
leaveGroup(actorId: Int!, groupId: Int!): DeletedMember
|
||||
leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
|
||||
|
||||
"""Login an user"""
|
||||
login(email: String!, password: String!): Login
|
||||
@ -1019,6 +1061,7 @@ type RootQueryType {
|
||||
|
||||
"""Get the instance config"""
|
||||
config: Config
|
||||
dashboard: Dashboard
|
||||
|
||||
"""Get an event by uuid"""
|
||||
event(uuid: UUID!): Event
|
||||
@ -1054,7 +1097,7 @@ type RootQueryType {
|
||||
report(id: ID!): Report
|
||||
|
||||
"""Get all reports"""
|
||||
reports(limit: Int = 10, page: Int = 1): [Report]
|
||||
reports(limit: Int = 10, page: Int = 1, status: ReportStatus = OPEN): [Report]
|
||||
|
||||
"""Reverse geocode coordinates"""
|
||||
reverseGeocode(latitude: Float!, longitude: Float!): [Address]
|
||||
@ -1144,6 +1187,15 @@ type User {
|
||||
|
||||
"""The token sent when requesting password token"""
|
||||
resetPasswordToken: String
|
||||
|
||||
"""The role for the user"""
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMINISTRATOR
|
||||
MODERATOR
|
||||
USER
|
||||
}
|
||||
|
||||
"""Users list"""
|
||||
|
@ -15,6 +15,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
|
||||
alias Mobilizon.Service.HTTPSignatures.Signature
|
||||
alias Mobilizon.Service.ActivityPub
|
||||
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
|
||||
import Mock
|
||||
|
||||
setup_all do
|
||||
HTTPoison.start()
|
||||
@ -111,7 +112,6 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
|
||||
end
|
||||
|
||||
describe "deletion" do
|
||||
# TODO: The delete activity it relayed and fetched once again (and then not found /o\)
|
||||
test "it creates a delete activity and deletes the original event" do
|
||||
event = insert(:event)
|
||||
event = Events.get_event_full_by_url!(event.url)
|
||||
@ -124,6 +124,25 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
|
||||
assert Events.get_event_by_url(event.url) == nil
|
||||
end
|
||||
|
||||
test "it deletes the original event but only locally if needed" do
|
||||
with_mock ActivityPub.Utils,
|
||||
maybe_federate: fn _ -> :ok end,
|
||||
lazy_put_activity_defaults: fn args -> args end do
|
||||
event = insert(:event)
|
||||
event = Events.get_event_full_by_url!(event.url)
|
||||
{:ok, delete, _} = ActivityPub.delete(event, false)
|
||||
|
||||
assert delete.data["type"] == "Delete"
|
||||
assert delete.data["actor"] == event.organizer_actor.url
|
||||
assert delete.data["object"] == event.url
|
||||
assert delete.local == false
|
||||
|
||||
assert Events.get_event_by_url(event.url) == nil
|
||||
|
||||
assert_called(ActivityPub.Utils.maybe_federate(delete))
|
||||
end
|
||||
end
|
||||
|
||||
test "it creates a delete activity and deletes the original comment" do
|
||||
comment = insert(:comment)
|
||||
comment = Events.get_comment_full_from_url!(comment.url)
|
||||
|
@ -22,7 +22,7 @@ defmodule Mobilizon.Service.Admin.ActionLogServiceTest do
|
||||
%ActionLog{
|
||||
target_type: "Elixir.Mobilizon.Reports.Report",
|
||||
target_id: report_id,
|
||||
action: "update",
|
||||
action: :update,
|
||||
actor: moderator
|
||||
}} = log_action(moderator, "update", report)
|
||||
end
|
||||
@ -35,7 +35,7 @@ defmodule Mobilizon.Service.Admin.ActionLogServiceTest do
|
||||
%ActionLog{
|
||||
target_type: "Elixir.Mobilizon.Reports.Note",
|
||||
target_id: note_id,
|
||||
action: "create",
|
||||
action: :create,
|
||||
actor: moderator
|
||||
}} = log_action(moderator, "create", report)
|
||||
end
|
||||
|
@ -25,7 +25,7 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||
Reports.report(%{
|
||||
reporter_actor_id: reporter_id,
|
||||
reported_actor_id: reported_id,
|
||||
report_content: comment,
|
||||
content: comment,
|
||||
event_id: event_id,
|
||||
comments_ids: []
|
||||
})
|
||||
@ -58,7 +58,7 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||
Reports.report(%{
|
||||
reporter_actor_id: reporter_id,
|
||||
reported_actor_id: reported_id,
|
||||
report_content: comment,
|
||||
content: comment,
|
||||
event_id: nil,
|
||||
comments_ids: [comment_1_id, comment_2_id]
|
||||
})
|
||||
@ -92,7 +92,7 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||
Reports.report(%{
|
||||
reporter_actor_id: reporter_id,
|
||||
reported_actor_id: reported_id,
|
||||
report_content: comment,
|
||||
content: comment,
|
||||
event_id: nil,
|
||||
comments_ids: [comment_1_id, comment_2_id],
|
||||
forward: true
|
||||
@ -121,7 +121,7 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||
Reports.report(%{
|
||||
reporter_actor_id: reporter_id,
|
||||
reported_actor_id: reported_id,
|
||||
report_content: "This is not a nice thing",
|
||||
content: "This is not a nice thing",
|
||||
event_id: nil,
|
||||
comments_ids: [comment_1_id],
|
||||
forward: true
|
||||
@ -147,7 +147,7 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||
Reports.report(%{
|
||||
reporter_actor_id: reporter_id,
|
||||
reported_actor_id: reported_id,
|
||||
report_content: "This is not a nice thing",
|
||||
content: "This is not a nice thing",
|
||||
event_id: nil,
|
||||
comments_ids: [comment_1_id],
|
||||
forward: true
|
||||
|
@ -31,8 +31,9 @@ defmodule MobilizonWeb.NodeInfoControllerTest do
|
||||
end
|
||||
|
||||
test "Get node info", %{conn: conn} do
|
||||
# We clear the cache because it might have been initialized by other tests
|
||||
Cachex.clear(:statistics)
|
||||
conn = get(conn, node_info_path(conn, :nodeinfo, "2.1"))
|
||||
|
||||
resp = json_response(conn, 200)
|
||||
|
||||
assert resp == %{
|
||||
@ -44,7 +45,7 @@ defmodule MobilizonWeb.NodeInfoControllerTest do
|
||||
"protocols" => ["activitypub"],
|
||||
"services" => %{"inbound" => [], "outbound" => ["atom1.0"]},
|
||||
"software" => %{
|
||||
"name" => "mobilizon",
|
||||
"name" => "Mobilizon",
|
||||
"version" => Keyword.get(@instance, :version),
|
||||
"repository" => Keyword.get(@instance, :repository)
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ defmodule MobilizonWeb.Resolvers.AdminResolverTest do
|
||||
use MobilizonWeb.ConnCase
|
||||
import Mobilizon.Factory
|
||||
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Reports.{Report, Note}
|
||||
@ -62,21 +63,60 @@ defmodule MobilizonWeb.Resolvers.AdminResolverTest do
|
||||
|
||||
assert json_response(res, 200)["data"]["actionLogs"] == [
|
||||
%{
|
||||
"action" => "report_update_resolved",
|
||||
"action" => "NOTE_DELETION",
|
||||
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
|
||||
"object" => %{"content" => @note_content}
|
||||
},
|
||||
%{
|
||||
"action" => "NOTE_CREATION",
|
||||
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
|
||||
"object" => %{"content" => @note_content}
|
||||
},
|
||||
%{
|
||||
"action" => "REPORT_UPDATE_RESOLVED",
|
||||
"actor" => %{"preferredUsername" => moderator.preferred_username},
|
||||
"object" => %{"id" => to_string(report.id), "status" => "RESOLVED"}
|
||||
},
|
||||
%{
|
||||
"action" => "note_creation",
|
||||
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
|
||||
"object" => %{"content" => @note_content}
|
||||
},
|
||||
%{
|
||||
"action" => "note_deletion",
|
||||
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
|
||||
"object" => %{"content" => @note_content}
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "Resolver: Get the dashboard statistics" do
|
||||
test "get_dashboard/3 gets dashboard information", %{conn: conn} do
|
||||
%Event{title: title} = insert(:event)
|
||||
|
||||
%User{} = user_admin = insert(:user, role: :administrator)
|
||||
|
||||
query = """
|
||||
{
|
||||
dashboard {
|
||||
lastPublicEventPublished {
|
||||
title
|
||||
}
|
||||
numberOfUsers,
|
||||
numberOfComments,
|
||||
numberOfEvents,
|
||||
numberOfReports
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
res =
|
||||
conn
|
||||
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
|
||||
|
||||
assert json_response(res, 200)["errors"] |> hd |> Map.get("message") ==
|
||||
"You need to be logged-in and an administrator to access dashboard statistics"
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user_admin)
|
||||
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
|
||||
assert json_response(res, 200)["data"]["dashboard"]["lastPublicEventPublished"]["title"] ==
|
||||
title
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -731,7 +731,7 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["deleteEvent"]["id"] == event.id
|
||||
assert json_response(res, 200)["data"]["deleteEvent"]["id"] == to_string(event.id)
|
||||
|
||||
res =
|
||||
conn
|
||||
@ -815,6 +815,72 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
|
||||
assert hd(json_response(res, 200)["errors"])["message"] =~ "cannot delete"
|
||||
end
|
||||
|
||||
test "delete_event/3 allows a event being deleted by a moderator and creates a entry in actionLogs",
|
||||
%{
|
||||
conn: conn,
|
||||
user: _user,
|
||||
actor: _actor
|
||||
} do
|
||||
user_moderator = insert(:user, role: :moderator)
|
||||
actor_moderator = insert(:actor, user: user_moderator)
|
||||
|
||||
actor2 = insert(:actor)
|
||||
event = insert(:event, organizer_actor: actor2)
|
||||
|
||||
mutation = """
|
||||
mutation {
|
||||
deleteEvent(
|
||||
actor_id: #{actor_moderator.id},
|
||||
event_id: #{event.id}
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user_moderator)
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["data"]["deleteEvent"]["id"] == to_string(event.id)
|
||||
|
||||
query = """
|
||||
{
|
||||
actionLogs {
|
||||
action,
|
||||
actor {
|
||||
preferredUsername
|
||||
},
|
||||
object {
|
||||
... on Report {
|
||||
id,
|
||||
status
|
||||
},
|
||||
... on ReportNote {
|
||||
content
|
||||
}
|
||||
... on Event {
|
||||
id,
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user_moderator)
|
||||
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
|
||||
|
||||
assert hd(json_response(res, 200)["data"]["actionLogs"]) == %{
|
||||
"action" => "EVENT_DELETION",
|
||||
"actor" => %{"preferredUsername" => actor_moderator.preferred_username},
|
||||
"object" => %{"title" => event.title, "id" => to_string(event.id)}
|
||||
}
|
||||
end
|
||||
|
||||
test "list_related_events/3 should give related events", %{
|
||||
conn: conn,
|
||||
actor: actor
|
||||
|
@ -43,7 +43,8 @@ defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do
|
||||
assert json_response(res, 200)["data"]["createFeedToken"]["user"]["id"] ==
|
||||
to_string(user.id)
|
||||
|
||||
assert json_response(res, 200)["data"]["createFeedToken"]["actor"]["id"] == actor2.id
|
||||
assert json_response(res, 200)["data"]["createFeedToken"]["actor"]["id"] ==
|
||||
to_string(actor2.id)
|
||||
|
||||
# The token is present for the user
|
||||
query = """
|
||||
@ -209,8 +210,12 @@ defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["deleteFeedToken"]["user"]["id"] == user.id
|
||||
assert json_response(res, 200)["data"]["deleteFeedToken"]["actor"]["id"] == actor.id
|
||||
|
||||
assert json_response(res, 200)["data"]["deleteFeedToken"]["user"]["id"] ==
|
||||
to_string(user.id)
|
||||
|
||||
assert json_response(res, 200)["data"]["deleteFeedToken"]["actor"]["id"] ==
|
||||
to_string(actor.id)
|
||||
|
||||
query = """
|
||||
{
|
||||
|
@ -163,7 +163,7 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["deleteGroup"]["id"] == group.id
|
||||
assert json_response(res, 200)["data"]["deleteGroup"]["id"] == to_string(group.id)
|
||||
|
||||
res =
|
||||
conn
|
||||
|
@ -40,8 +40,8 @@ defmodule MobilizonWeb.Resolvers.MemberResolverTest do
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["joinGroup"]["role"] == "not_approved"
|
||||
assert json_response(res, 200)["data"]["joinGroup"]["parent"]["id"] == group.id
|
||||
assert json_response(res, 200)["data"]["joinGroup"]["actor"]["id"] == actor.id
|
||||
assert json_response(res, 200)["data"]["joinGroup"]["parent"]["id"] == to_string(group.id)
|
||||
assert json_response(res, 200)["data"]["joinGroup"]["actor"]["id"] == to_string(actor.id)
|
||||
|
||||
mutation = """
|
||||
mutation {
|
||||
@ -167,8 +167,8 @@ defmodule MobilizonWeb.Resolvers.MemberResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["leaveGroup"]["parent"]["id"] == group.id
|
||||
assert json_response(res, 200)["data"]["leaveGroup"]["actor"]["id"] == actor.id
|
||||
assert json_response(res, 200)["data"]["leaveGroup"]["parent"]["id"] == to_string(group.id)
|
||||
assert json_response(res, 200)["data"]["leaveGroup"]["actor"]["id"] == to_string(actor.id)
|
||||
end
|
||||
|
||||
test "leave_group/3 should check if the member is the only administrator", %{
|
||||
|
@ -50,8 +50,8 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "participant"
|
||||
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == event.id
|
||||
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == actor.id
|
||||
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
|
||||
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
|
||||
|
||||
mutation = """
|
||||
mutation {
|
||||
@ -119,7 +119,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert hd(json_response(res, 200)["errors"])["message"] ==
|
||||
"Event with this ID 1042 doesn't exist"
|
||||
"Event with this ID \"1042\" doesn't exist"
|
||||
end
|
||||
|
||||
test "actor_leave_event/3 should delete a participant from an event", %{
|
||||
@ -153,8 +153,10 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["leaveEvent"]["event"]["id"] == event.id
|
||||
assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] == participant.actor.id
|
||||
assert json_response(res, 200)["data"]["leaveEvent"]["event"]["id"] == to_string(event.id)
|
||||
|
||||
assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] ==
|
||||
to_string(participant.actor.id)
|
||||
|
||||
query = """
|
||||
{
|
||||
|
@ -21,7 +21,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
reporter_actor_id: #{reporter.id},
|
||||
reported_actor_id: #{reported.id},
|
||||
event_id: #{event.id},
|
||||
report_content: "This is an issue"
|
||||
content: "This is an issue"
|
||||
) {
|
||||
content,
|
||||
reporter {
|
||||
@ -43,8 +43,10 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["createReport"]["content"] == "This is an issue"
|
||||
assert json_response(res, 200)["data"]["createReport"]["status"] == "OPEN"
|
||||
assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == event.id
|
||||
assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] == reporter.id
|
||||
assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == to_string(event.id)
|
||||
|
||||
assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] ==
|
||||
to_string(reporter.id)
|
||||
end
|
||||
|
||||
test "create_report/3 without being connected doesn't create any report", %{conn: conn} do
|
||||
@ -55,7 +57,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
createReport(
|
||||
reported_actor_id: #{reported.id},
|
||||
reporter_actor_id: 5,
|
||||
report_content: "This is an issue"
|
||||
content: "This is an issue"
|
||||
) {
|
||||
content
|
||||
}
|
||||
@ -109,7 +111,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
assert json_response(res, 200)["data"]["updateReportStatus"]["status"] == "RESOLVED"
|
||||
|
||||
assert json_response(res, 200)["data"]["updateReportStatus"]["reporter"]["id"] ==
|
||||
report.reporter.id
|
||||
to_string(report.reporter.id)
|
||||
end
|
||||
|
||||
test "create_report/3 without being connected doesn't create any report", %{conn: conn} do
|
||||
@ -172,9 +174,14 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
test "get a list of reports", %{conn: conn} do
|
||||
%User{} = user_moderator = insert(:user, role: :moderator)
|
||||
|
||||
%Report{id: report_1_id} = insert(:report)
|
||||
%Report{id: report_2_id} = insert(:report)
|
||||
%Report{id: report_3_id} = insert(:report)
|
||||
# Report don't hold millisecond information so we need to wait a bit
|
||||
# between each insert to keep order
|
||||
%Report{id: report_1_id} = insert(:report, content: "My content 1")
|
||||
Process.sleep(1000)
|
||||
%Report{id: report_2_id} = insert(:report, content: "My content 2")
|
||||
Process.sleep(1000)
|
||||
%Report{id: report_3_id} = insert(:report, content: "My content 3")
|
||||
%Report{} = insert(:report, status: :closed)
|
||||
|
||||
query = """
|
||||
{
|
||||
@ -182,7 +189,9 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
id,
|
||||
reported {
|
||||
preferredUsername
|
||||
}
|
||||
},
|
||||
content,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
"""
|
||||
@ -196,7 +205,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
|
||||
assert json_response(res, 200)["data"]["reports"]
|
||||
|> Enum.map(fn report -> Map.get(report, "id") end) ==
|
||||
Enum.map([report_1_id, report_2_id, report_3_id], &to_string/1)
|
||||
Enum.map([report_3_id, report_2_id, report_1_id], &to_string/1)
|
||||
|
||||
query = """
|
||||
{
|
||||
@ -360,7 +369,9 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
||||
|
||||
assert json_response(res, 200)["errors"] == nil
|
||||
assert json_response(res, 200)["data"]["deleteReportNote"]["id"] == report_note_id
|
||||
|
||||
assert json_response(res, 200)["data"]["deleteReportNote"]["id"] ==
|
||||
to_string(report_note_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user