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:
Thomas Citharel 2019-09-10 09:11:07 +02:00
commit f47670c85f
77 changed files with 1682 additions and 201 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View 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
View 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);
},
};

View File

@ -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
View 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
}
}
`;

View File

@ -7,7 +7,8 @@ mutation Login($email: String!, $password: String!) {
refreshToken,
user {
id,
email
email,
role
}
},
}

View File

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

View File

@ -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
View 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
}
}
`;

View File

@ -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
}
`;

View File

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

View File

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

View 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 },
},
];

View File

@ -0,0 +1,9 @@
import { IEvent } from '@/types/event.model';
export interface IDashboard {
lastPublicEventPublished: IEvent;
numberOfUsers: number;
numberOfEvents: number;
numberOfComments: number;
numberOfReports: number;
}

View File

@ -1,5 +1,12 @@
export enum ICurrentUserRole {
USER = 'USER',
MODERATOR = 'MODERATOR',
ADMINISTRATOR = 'ADMINISTRATOR',
}
export interface ICurrentUser {
id: number;
email: string;
isLoggedIn: boolean;
role: ICurrentUserRole;
}

View 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;
}

View File

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

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

View File

@ -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;
@ -298,11 +311,34 @@ export default class Event extends Vue {
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,
},
});

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api
# timestamp: Mon Sep 09 2019 11:37:31 GMT+0200 (heure dété dEurope 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"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = """
{

View File

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

View File

@ -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", %{

View File

@ -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 = """
{

View File

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