Allow to accept / reject participants

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-09-20 18:22:03 +02:00
parent ffa4ec9209
commit abf3a58657
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
31 changed files with 1208 additions and 299 deletions

View File

@ -82,6 +82,7 @@ export default class App extends Vue {
@import "~bulma/sass/components/dropdown.sass"; @import "~bulma/sass/components/dropdown.sass";
@import "~bulma/sass/components/breadcrumb.sass"; @import "~bulma/sass/components/breadcrumb.sass";
@import "~bulma/sass/components/list.sass"; @import "~bulma/sass/components/list.sass";
@import "~bulma/sass/components/tabs";
@import "~bulma/sass/elements/box.sass"; @import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass"; @import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass"; @import "~bulma/sass/elements/container.sass";
@ -112,6 +113,7 @@ export default class App extends Vue {
@import "~buefy/src/scss/components/radio"; @import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch"; @import "~buefy/src/scss/components/switch";
@import "~buefy/src/scss/components/table"; @import "~buefy/src/scss/components/table";
@import "~buefy/src/scss/components/tabs";
.router-enter-active, .router-enter-active,
.router-leave-active { .router-leave-active {

View File

@ -1,10 +1,10 @@
<template> <template>
<span> <span>
<router-link v-if="actor.domain === null" <span v-if="actor.domain === null"
:to="{name: 'Profile', params: { name: actor.preferredUsername } }" :to="{name: 'Profile', params: { name: actor.preferredUsername } }"
> >
<slot></slot> <slot></slot>
</router-link> </span>
<a v-else :href="actor.url"> <a v-else :href="actor.url">
<slot></slot> <slot></slot>
</a> </a>

View File

@ -0,0 +1,48 @@
<template>
<article class="card">
<div class="card-content">
<div class="media">
<div class="media-left" v-if="participant.actor.avatar">
<figure class="image is-48x48">
<img :src="participant.actor.avatar.url" />
</figure>
</div>
<div class="media-content">
<span class="title" ref="title">{{ actorDisplayName }}</span><br>
<small class="has-text-grey">@{{ participant.actor.preferredUsername }}</small>
</div>
</div>
</div>
<footer class="card-footer">
<b-button v-if="participant.role === ParticipantRole.NOT_APPROVED" @click="accept(participant)" type="is-success" class="card-footer-item">{{ $t('Approve') }}</b-button>
<b-button v-if="participant.role === ParticipantRole.NOT_APPROVED" @click="reject(participant)" type="is-danger" class="card-footer-item">{{ $t('Reject')}} </b-button>
<b-button v-if="participant.role === ParticipantRole.PARTICIPANT" @click="exclude(participant)" type="is-danger" class="card-footer-item">{{ $t('Exclude')}} </b-button>
<span v-if="participant.role === ParticipantRole.CREATOR" class="card-footer-item">{{ $t('Creator')}} </span>
</footer>
</article>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor, IPerson, Person } from '@/types/actor';
import { IParticipant, ParticipantRole } from '@/types/event.model';
@Component
export default class ActorCard extends Vue {
@Prop({ required: true }) participant!: IParticipant;
@Prop({ type: Function }) accept;
@Prop({ type: Function }) reject;
@Prop({ type: Function }) exclude;
ParticipantRole = ParticipantRole;
get actorDisplayName(): string {
const actor = new Person(this.participant.actor);
return actor.displayName();
}
}
</script>
<style lang="scss">
</style>

View File

@ -60,7 +60,6 @@ export interface IEventCardOptions {
@Component({ @Component({
components: { components: {
DateCalendarIcon, DateCalendarIcon,
EventCard,
}, },
mounted() { mounted() {
lineClamp(this.$refs.title, 3); lineClamp(this.$refs.title, 3);

View File

@ -11,7 +11,8 @@
<span v-if="participation.event.physicalAddress && participation.event.physicalAddress.locality">{{ participation.event.physicalAddress.locality }} - </span> <span v-if="participation.event.physicalAddress && participation.event.physicalAddress.locality">{{ participation.event.physicalAddress.locality }} - </span>
<span v-if="participation.actor.id === participation.event.organizerActor.id">{{ $t("You're organizing this event") }}</span> <span v-if="participation.actor.id === participation.event.organizerActor.id">{{ $t("You're organizing this event") }}</span>
<span v-else> <span v-else>
<span>{{ $t('Organized by {name}', { name: participation.event.organizerActor.displayName() } ) }}</span> | <span v-if="participation.event.beginsOn < new Date()">{{ $t('Organized by {name}', { name: participation.event.organizerActor.displayName() } ) }}</span>
|
<span>{{ $t('Going as {name}', { name: participation.actor.displayName() }) }}</span> <span>{{ $t('Going as {name}', { name: participation.actor.displayName() }) }}</span>
</span> </span>
</div> </div>
@ -50,9 +51,9 @@
<a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a> <a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a>
</li> </li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))"> <li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click=""> <router-link :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } }">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }} <b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</a> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link> <router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Join event {{ event.title }}</p> <p class="modal-card-title">{{ $t('Join event {title}', {title: event.title}) }}</p>
</header> </header>
<section class="modal-card-body is-flex"> <section class="modal-card-body is-flex">
@ -14,14 +14,18 @@
size="is-large"/> size="is-large"/>
</div> </div>
<div class="media-content"> <div class="media-content">
<p>Do you want to participate in {{ event.title }}?</p> <p>{{ $t('Do you want to participate in {title}?', {title: event.title}) }}?</p>
<b-field :label="$t('Identity')"> <b-field :label="$t('Identity')">
<identity-picker v-model="identity"></identity-picker> <identity-picker v-model="identity"></identity-picker>
</b-field> </b-field>
<p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">
{{ $t('The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved')}}
</p>
<p v-if="!event.local"> <p v-if="!event.local">
The event came from another instance. Your participation will be confirmed after we confirm it with the other instance. {{ $t('The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.') }}
</p> </p>
</div> </div>
</div> </div>
@ -32,13 +36,13 @@
class="button" class="button"
ref="cancelButton" ref="cancelButton"
@click="close"> @click="close">
Cancel {{ $t('Cancel') }}
</button> </button>
<button <button
class="button is-primary" class="button is-primary"
ref="confirmButton" ref="confirmButton"
@click="confirm"> @click="confirm">
Confirm my particpation {{ $t('Confirm my particpation') }}
</button> </button>
</footer> </footer>
</div> </div>
@ -46,7 +50,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent } from '@/types/event.model'; import { IEvent, EventJoinOptions } from '@/types/event.model';
import IdentityPicker from '@/views/Account/IdentityPicker.vue'; import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
@ -66,6 +70,8 @@ export default class ReportModal extends Vue {
isActive: boolean = false; isActive: boolean = false;
identity: IPerson = this.defaultIdentity; identity: IPerson = this.defaultIdentity;
EventJoinOptions = EventJoinOptions;
confirm() { confirm() {
this.onConfirm(this.identity); this.onConfirm(this.identity);
} }

View File

@ -16,24 +16,23 @@
size="is-large"/> size="is-large"/>
</div> </div>
<div class="media-content"> <div class="media-content">
<p>The report will be sent to the moderators of your instance. <p>{{ $t('The report will be sent to the moderators of your instance. You can explain why you report this content below.') }}</p>
You can explain why you report this content below.</p>
<div class="control"> <div class="control">
<b-input <b-input
v-model="content" v-model="content"
type="textarea" type="textarea"
@keyup.enter="confirm" @keyup.enter="confirm"
placeholder="Additional comments" :placeholder="$t('Additional comments')"
/> />
</div> </div>
<p v-if="outsideDomain"> <p v-if="outsideDomain">
The content came from another server. Transfer an anonymous copy of the report ? {{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}
</p> </p>
<div class="control" v-if="outsideDomain"> <div class="control" v-if="outsideDomain">
<b-switch v-model="forward">Transfer to {{ outsideDomain }}</b-switch> <b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
</div> </div>
</div> </div>
</div> </div>
@ -44,13 +43,13 @@
class="button" class="button"
ref="cancelButton" ref="cancelButton"
@click="close"> @click="close">
{{ cancelText }} {{ translatedCancelText }}
</button> </button>
<button <button
class="button is-primary" class="button is-primary"
ref="confirmButton" ref="confirmButton"
@click="confirm"> @click="confirm">
{{ confirmText }} {{ translatedConfirmText }}
</button> </button>
</footer> </footer>
</div> </div>
@ -69,13 +68,21 @@ export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm; @Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: String }) title; @Prop({ type: String }) title;
@Prop({ type: String, default: '' }) outsideDomain; @Prop({ type: String, default: '' }) outsideDomain;
@Prop({ type: String, default: 'Cancel' }) cancelText; @Prop({ type: String }) cancelText;
@Prop({ type: String, default: 'Send the report' }) confirmText; @Prop({ type: String }) confirmText;
isActive: boolean = false; isActive: boolean = false;
content: string = ''; content: string = '';
forward: boolean = false; forward: boolean = false;
get translatedCancelText() {
return this.cancelText || this.$t('Cancel');
}
get translatedConfirmText() {
return this.confirmText || this.$t('Send the report');
}
confirm() { confirm() {
this.onConfirm(this.content, this.forward); this.onConfirm(this.content, this.forward);
this.close(); this.close();

View File

@ -91,6 +91,7 @@ query LoggedUserParticipations($afterDateTime: DateTime, $beforeDateTime: DateTi
remainingAttendeeCapacity remainingAttendeeCapacity
} }
}, },
id,
role, role,
actor { actor {
id, id,

View File

@ -2,6 +2,7 @@ import gql from 'graphql-tag';
const participantQuery = ` const participantQuery = `
role, role,
id,
actor { actor {
preferredUsername, preferredUsername,
avatar { avatar {
@ -50,7 +51,7 @@ const optionsQuery = `
`; `;
export const FETCH_EVENT = gql` export const FETCH_EVENT = gql`
query($uuid:UUID!) { query($uuid:UUID!, $roles: String) {
event(uuid: $uuid) { event(uuid: $uuid) {
id, id,
uuid, uuid,
@ -63,6 +64,7 @@ export const FETCH_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
joinOptions,
picture { picture {
id id
url url
@ -92,7 +94,7 @@ export const FETCH_EVENT = gql`
# preferredUsername, # preferredUsername,
# name, # name,
# }, # },
participants { participants (roles: $roles) {
${participantQuery} ${participantQuery}
}, },
participantStats { participantStats {
@ -183,7 +185,8 @@ export const CREATE_EVENT = gql`
$beginsOn: DateTime!, $beginsOn: DateTime!,
$endsOn: DateTime, $endsOn: DateTime,
$status: EventStatus, $status: EventStatus,
$visibility: EventVisibility $visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: PictureInput,
$onlineAddress: String, $onlineAddress: String,
@ -200,6 +203,7 @@ export const CREATE_EVENT = gql`
endsOn: $endsOn, endsOn: $endsOn,
status: $status, status: $status,
visibility: $visibility, visibility: $visibility,
joinOptions: $joinOptions,
tags: $tags, tags: $tags,
picture: $picture, picture: $picture,
onlineAddress: $onlineAddress, onlineAddress: $onlineAddress,
@ -216,6 +220,7 @@ export const CREATE_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
joinOptions,
picture { picture {
id id
url url
@ -245,7 +250,8 @@ export const EDIT_EVENT = gql`
$beginsOn: DateTime, $beginsOn: DateTime,
$endsOn: DateTime, $endsOn: DateTime,
$status: EventStatus, $status: EventStatus,
$visibility: EventVisibility $visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: PictureInput,
$onlineAddress: String, $onlineAddress: String,
@ -262,6 +268,7 @@ export const EDIT_EVENT = gql`
endsOn: $endsOn, endsOn: $endsOn,
status: $status, status: $status,
visibility: $visibility, visibility: $visibility,
joinOptions: $joinOptions,
tags: $tags, tags: $tags,
picture: $picture, picture: $picture,
onlineAddress: $onlineAddress, onlineAddress: $onlineAddress,
@ -278,6 +285,7 @@ export const EDIT_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
joinOptions,
picture { picture {
id id
url url
@ -323,6 +331,23 @@ export const LEAVE_EVENT = gql`
} }
`; `;
export const ACCEPT_PARTICIPANT = gql`
mutation AcceptParticipant($id: ID!, $moderatorActorId: ID!) {
acceptParticipation(id: $id, moderatorActorId: $moderatorActorId) {
role,
id
}
}
`;
export const REJECT_PARTICIPANT = gql`
mutation RejectParticipant($id: ID!, $moderatorActorId: ID!) {
rejectParticipation(id: $id, moderatorActorId: $moderatorActorId) {
id
}
}
`;
export const DELETE_EVENT = gql` export const DELETE_EVENT = gql`
mutation DeleteEvent($eventId: ID!, $actorId: ID!) { mutation DeleteEvent($eventId: ID!, $actorId: ID!) {
deleteEvent( deleteEvent(
@ -333,3 +358,17 @@ export const DELETE_EVENT = gql`
} }
} }
`; `;
export const PARTICIPANTS = gql`
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
event(uuid: $uuid) {
participants(page: $page, limit: $limit, roles: $roles) {
${participantQuery}
},
participantStats {
approved,
unapproved
}
}
}
`;

View File

@ -211,5 +211,29 @@
"Load more": "Load more", "Load more": "Load more",
"Past events": "Passed events", "Past events": "Passed events",
"View everything": "View everything", "View everything": "View everything",
"Last week": "Last week" "Last week": "Last week",
"Approve": "Approve",
"Reject": "Reject",
"Exclude": "Exclude",
"Creator": "Creator",
"Join event {title}": "Join event {title}",
"Cancel": "Cancel",
"Confirm my particpation": "Confirm my particpation",
"Manage participants": "Manage participants",
"No participants yet.": "No participants yet.",
"Participants": "Participants",
"Do you want to participate in {title}?": "Do you want to participate in {title}?",
"The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved": "The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved",
"The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.": "The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.",
"Waiting list": "Waiting list",
"Leaving event \"{title}\"": "Leaving event \"{title}\"",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Are you sure you want to cancel your participation at event \"{title}\"?",
"Leave event": "Leave event",
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "The report will be sent to the moderators of your instance. You can explain why you report this content below.",
"Additional comments": "Additional comments",
"The content came from another server. Transfer an anonymous copy of the report?": "The content came from another server. Transfer an anonymous copy of the report ?",
"Transfer to {outsideDomain}": "Transfer to {outsideDomain}",
"Send the report": "Send the report",
"Report this event": "Report this event"
} }

View File

@ -138,7 +138,7 @@
"Register an account on Mobilizon!": "S'inscrire sur Mobilizon !", "Register an account on Mobilizon!": "S'inscrire sur Mobilizon !",
"Register": "S'inscrire", "Register": "S'inscrire",
"Registration is currently closed.": "Les inscriptions sont actuellement fermées.", "Registration is currently closed.": "Les inscriptions sont actuellement fermées.",
"Report": "Report", "Report": "Signaler",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation", "Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Reset my password": "Réinitialiser mon mot de passe", "Reset my password": "Réinitialiser mon mot de passe",
"Save": "Enregistrer", "Save": "Enregistrer",
@ -211,5 +211,28 @@
"Load more": "Voir plus", "Load more": "Voir plus",
"Past events": "Événements passés", "Past events": "Événements passés",
"View everything": "Voir tout", "View everything": "Voir tout",
"Last week": "La semaine dernière" "Last week": "La semaine dernière",
"Approve": "Approuver",
"Reject": "Rejetter",
"Exclude": "Exclure",
"Creator": "Créateur",
"Join event {title}": "Rejoindre {title}",
"Cancel": "Annuler",
"Confirm my particpation": "Confirmer ma particpation",
"Manage participants": "Gérer les participants",
"No participants yet.": "Pas de participants pour le moment.",
"Participants": "Participants",
"Do you want to participate in {title}?": "Voulez-vous participer à {title} ?",
"The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved": "L'organisateur⋅ice de l'événement a choisi d'approuver manuellement les participations à cet événement. Vous recevrez une notification lorsque votre participation sera approuvée",
"The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.": "L'événement provient d'une autre instance. Votre participation sera confirmée après que nous ayons la confirmation de l'autre instance.",
"Waiting list": "Liste d'attente",
"Leaving event \"{title}\"": "Annuler ma participation à l'événement",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'événement « {title} » ?",
"Leave event": "Annuler ma participation à l'événement",
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "Le signalement sera envoyé aux modérateur⋅ices de votre instance. Vous pouvez expliquer pourquoi vous signalez ce contenu ci-dessous.",
"Additional comments": "Commentaires additionnels",
"The content came from another server. Transfer an anonymous copy of the report?": "Le contenu provient d'une autre instance. Transférer une copie anonyme du signalement ?",
"Transfer to {outsideDomain}": "Transférer à {outsideDomain}",
"Send the report": "Envoyer le signalement",
"Report this event": "Signaler cet événement"
} }

View File

@ -3,9 +3,10 @@ import Location from '@/views/Location.vue';
import { RouteConfig } from 'vue-router'; import { RouteConfig } from 'vue-router';
// tslint:disable:space-in-parens // tslint:disable:space-in-parens
const editEvent = () => import(/* webpackChunkName: "create-event" */ '@/views/Event/Edit.vue'); const participations = () => import(/* webpackChunkName: "participations" */ '@/views/Event/Participants.vue');
const editEvent = () => import(/* webpackChunkName: "edit-event" */ '@/views/Event/Edit.vue');
const event = () => import(/* webpackChunkName: "event" */ '@/views/Event/Event.vue'); const event = () => import(/* webpackChunkName: "event" */ '@/views/Event/Event.vue');
const myEvents = () => import(/* webpackChunkName: "event" */ '@/views/Event/MyEvents.vue'); const myEvents = () => import(/* webpackChunkName: "my-events" */ '@/views/Event/MyEvents.vue');
// tslint:enable // tslint:enable
export enum EventRouteName { export enum EventRouteName {
@ -13,6 +14,7 @@ export enum EventRouteName {
CREATE_EVENT = 'CreateEvent', CREATE_EVENT = 'CreateEvent',
MY_EVENTS = 'MyEvents', MY_EVENTS = 'MyEvents',
EDIT_EVENT = 'EditEvent', EDIT_EVENT = 'EditEvent',
PARTICIPATIONS = 'Participations',
EVENT = 'Event', EVENT = 'Event',
LOCATION = 'Location', LOCATION = 'Location',
} }
@ -43,6 +45,13 @@ export const eventRoutes: RouteConfig[] = [
meta: { requiredAuth: true }, meta: { requiredAuth: true },
props: { isUpdate: true }, props: { isUpdate: true },
}, },
{
path: '/events/participations/:eventId',
name: EventRouteName.PARTICIPATIONS,
component: participations,
meta: { requiredAuth: true },
props: true,
},
{ {
path: '/location/new', path: '/location/new',
name: EventRouteName.LOCATION, name: EventRouteName.LOCATION,

View File

@ -29,11 +29,11 @@ export enum EventVisibilityJoinOptions {
} }
export enum ParticipantRole { export enum ParticipantRole {
NOT_APPROVED = 'not_approved', NOT_APPROVED = 'NOT_APPROVED',
PARTICIPANT = 'participant', PARTICIPANT = 'PARTICIPANT',
MODERATOR = 'moderator', MODERATOR = 'MODERATOR',
ADMINISTRATOR = 'administrator', ADMINISTRATOR = 'ADMINISTRATOR',
CREATOR = 'creator', CREATOR = 'CREATOR',
} }
export enum Category { export enum Category {
@ -45,12 +45,14 @@ export enum Category {
} }
export interface IParticipant { export interface IParticipant {
id?: string;
role: ParticipantRole; role: ParticipantRole;
actor: IActor; actor: IActor;
event: IEvent; event: IEvent;
} }
export class Participant implements IParticipant { export class Participant implements IParticipant {
id?: string;
event!: IEvent; event!: IEvent;
actor!: IActor; actor!: IActor;
role: ParticipantRole = ParticipantRole.NOT_APPROVED; role: ParticipantRole = ParticipantRole.NOT_APPROVED;
@ -58,6 +60,7 @@ export class Participant implements IParticipant {
constructor(hash?: IParticipant) { constructor(hash?: IParticipant) {
if (!hash) return; if (!hash) return;
this.id = hash.id;
this.event = new EventModel(hash.event); this.event = new EventModel(hash.event);
this.actor = new Actor(hash.actor); this.actor = new Actor(hash.actor);
this.role = hash.role; this.role = hash.role;
@ -83,7 +86,7 @@ export enum CommentModeration {
} }
export interface IEvent { export interface IEvent {
id?: number; id?: string;
uuid: string; uuid: string;
url: string; url: string;
local: boolean; local: boolean;
@ -147,7 +150,7 @@ export class EventOptions implements IEventOptions {
} }
export class EventModel implements IEvent { export class EventModel implements IEvent {
id?: number; id?: string;
beginsOn = new Date(); beginsOn = new Date();
endsOn: Date | null = new Date(); endsOn: Date | null = new Date();
@ -232,6 +235,7 @@ export class EventModel implements IEvent {
endsOn: this.endsOn ? this.endsOn.toISOString() : null, endsOn: this.endsOn ? this.endsOn.toISOString() : null,
status: this.status, status: this.status,
visibility: this.visibility, visibility: this.visibility,
joinOptions: this.joinOptions,
tags: this.tags.map(t => t.title), tags: this.tags.map(t => t.title),
picture: this.picture, picture: this.picture,
onlineAddress: this.onlineAddress, onlineAddress: this.onlineAddress,

View File

@ -1,3 +1,4 @@
import {EventJoinOptions} from "@/types/event.model";
<template> <template>
<section class="container"> <section class="container">
<h1 class="title" v-if="isUpdate === false"> <h1 class="title" v-if="isUpdate === false">
@ -187,11 +188,17 @@
</style> </style>
<script lang="ts"> <script lang="ts">
import { CREATE_EVENT, EDIT_EVENT, FETCH_EVENT, FETCH_EVENTS } from '@/graphql/event'; import { CREATE_EVENT, EDIT_EVENT, FETCH_EVENT } from '@/graphql/event';
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { EventModel, EventStatus, EventVisibility, EventVisibilityJoinOptions, CommentModeration, IEvent } from '@/types/event.model'; import {
CommentModeration, EventJoinOptions,
EventModel,
EventStatus,
EventVisibility,
EventVisibilityJoinOptions,
} from '@/types/event.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor'; import { Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue'; import PictureUpload from '@/components/PictureUpload.vue';
import Editor from '@/components/Editor.vue'; import Editor from '@/components/Editor.vue';
import DateTimePicker from '@/components/Event/DateTimePicker.vue'; import DateTimePicker from '@/components/Event/DateTimePicker.vue';
@ -352,6 +359,15 @@ export default class EditEvent extends Vue {
} }
} }
@Watch('needsApproval')
updateEventJoinOptions(needsApproval) {
if (needsApproval === true) {
this.event.joinOptions = EventJoinOptions.RESTRICTED;
} else {
this.event.joinOptions = EventJoinOptions.FREE;
}
}
// getAddressData(addressData) { // getAddressData(addressData) {
// if (addressData !== null) { // if (addressData !== null) {
// this.event.address = { // this.event.address = {

View File

@ -103,7 +103,7 @@
</b-modal> </b-modal>
</div> </div>
<div class="organizer"> <div class="organizer">
<actor-link :actor="event.organizerActor"> <span>
<span v-if="event.organizerActor"> <span v-if="event.organizerActor">
{{ $t('By {name}', {name: event.organizerActor.name ? event.organizerActor.name : event.organizerActor.preferredUsername}) }} {{ $t('By {name}', {name: event.organizerActor.name ? event.organizerActor.name : event.organizerActor.preferredUsername}) }}
</span> </span>
@ -113,31 +113,11 @@
:src="event.organizerActor.avatar.url" :src="event.organizerActor.avatar.url"
:alt="event.organizerActor.avatar.alt" /> :alt="event.organizerActor.avatar.alt" />
</figure> </figure>
</actor-link> </span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- <p v-if="actorIsOrganizer()">-->
<!-- <translate>You are an organizer.</translate>-->
<!-- </p>-->
<!-- <div v-else>-->
<!-- <p v-if="actorIsParticipant()">-->
<!-- <translate>You announced that you're going to this event.</translate>-->
<!-- </p>-->
<!-- <p v-else>-->
<!-- <translate>Are you going to this event?</translate><br />-->
<!-- <span>-->
<!-- <translate-->
<!-- :translate-n="event.participants.length"-->
<!-- translate-plural="{event.participants.length} persons are going"-->
<!-- >-->
<!-- One person is going.-->
<!-- </translate>-->
<!-- </span>-->
<!-- </p>-->
<!-- </div>-->
<div class="description"> <div class="description">
<div class="description-container container"> <div class="description-container container">
<h3 class="title"> <h3 class="title">
@ -147,63 +127,31 @@
{{ $t("The event organizer didn't add any description.") }} {{ $t("The event organizer didn't add any description.") }}
</p> </p>
<div class="columns" v-else> <div class="columns" v-else>
<div class="column is-half"> <div class="column is-half" v-html="event.description">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Suspendisse vehicula ex dapibus augue volutpat, ultrices cursus mi rutrum.
Nunc ante nunc, facilisis a tellus quis, tempor mollis diam. Aenean consectetur quis est a ultrices.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<p><a href="https://framasoft.org">https://framasoft.org</a>
<p>
Nam sit amet est eget velit tristique commodo. Etiam sollicitudin dignissim diam, ut ultricies tortor.
Sed quis blandit diam, a tincidunt nunc. Donec tincidunt tristique neque at rhoncus. Ut eget vulputate felis.
Pellentesque nibh purus, viverra ac augue sed, iaculis feugiat velit. Nulla ut hendrerit elit.
Etiam at justo eu nunc tempus sagittis. Sed ac tincidunt tellus, sit amet luctus velit.
Nam ullamcorper eros eleifend, eleifend diam vitae, lobortis risus.
</p>
<p>
<em>
Curabitur rhoncus sapien tortor, vitae imperdiet massa scelerisque non.
Aliquam eu augue mi. Donec hendrerit lorem orci.
</em>
</p>
<p>
Donec volutpat, enim eu laoreet dictum, urna quam varius enim, eu convallis urna est vitae massa.
Morbi porttitor lacus a sem efficitur blandit. Mauris in est in quam tincidunt iaculis non vitae ipsum.
Phasellus eget velit tellus. Curabitur ac neque pharetra velit viverra mollis.
</p>
<img src="https://framasoft.org/img/biglogo-notxt.png" alt="logo Framasoft"/>
<p>Aenean gravida, ante vitae aliquet aliquet, elit quam tristique orci, sit amet dictum lorem ipsum nec tortor.
Vestibulum est eros, faucibus et semper vel, dapibus ac est. Suspendisse potenti. Suspendisse potenti.
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
Nulla molestie nisi ac risus hendrerit, dapibus mattis sapien scelerisque.
</p>
<p>Maecenas id pretium justo, nec dignissim sapien. Mauris in venenatis odio, in congue augue. </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- <section class="container">--> <section class="container">
<!-- <h2 class="title">Participants</h2>--> <h3 class="title">{{ $t('Participants') }}</h3>
<!-- <span v-if="event.participants.length === 0">No participants yet.</span>--> <router-link v-if="currentActor.id === event.organizerActor.id" :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: event.uuid } }">
<!-- <div class="columns">--> {{ $t('Manage participants') }}
<!-- <router-link--> </router-link>
<!-- class="column"--> <span v-if="event.participants.length === 0">{{ $t('No participants yet.') }}</span>
<!-- v-for="participant in event.participants"--> <div class="columns">
<!-- :key="participant.preferredUsername"--> <div
<!-- :to="{name: 'Profile', params: { name: participant.actor.preferredUsername }}"--> class="column"
<!-- >--> v-for="participant in event.participants"
<!-- <div>--> :key="participant.id"
<!-- <figure>--> >
<!-- <img v-if="!participant.actor.avatar.url" src="https://picsum.photos/125/125/">--> <figure class="image is-48x48">
<!-- <img v-else :src="participant.actor.avatar.url">--> <img v-if="!participant.actor.avatar.url" src="https://picsum.photos/48/48/" class="is-rounded">
<!-- </figure>--> <img v-else :src="participant.actor.avatar.url" class="is-rounded">
<!-- <span>{{ participant.actor.preferredUsername }}</span>--> </figure>
<!-- </div>--> <span>{{ participant.actor.preferredUsername }}</span>
<!-- </router-link>--> </div>
<!-- </div>--> </div>
<!-- </section>--> </section>
<section class="share"> <section class="share">
<div class="container"> <div class="container">
<div class="columns"> <div class="columns">
@ -236,7 +184,7 @@
</div> </div>
</section> </section>
<b-modal :active.sync="isReportModalActive" has-modal-card ref="reportModal"> <b-modal :active.sync="isReportModalActive" has-modal-card ref="reportModal">
<report-modal :on-confirm="reportEvent" title="Report this event" :outside-domain="event.organizerActor.domain" @close="$refs.reportModal.close()" /> <report-modal :on-confirm="reportEvent" :title="$t('Report this event')" :outside-domain="event.organizerActor.domain" @close="$refs.reportModal.close()" />
</b-modal> </b-modal>
<b-modal :active.sync="isJoinModalActive" has-modal-card ref="participationModal"> <b-modal :active.sync="isJoinModalActive" has-modal-card ref="participationModal">
<participation-modal :on-confirm="joinEvent" :event="event" :defaultIdentity="currentActor" @close="$refs.participationModal.close()" /> <participation-modal :on-confirm="joinEvent" :event="event" :defaultIdentity="currentActor" @close="$refs.participationModal.close()" />
@ -249,7 +197,7 @@
import { DELETE_EVENT, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event'; import { DELETE_EVENT, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventVisibility, IEvent, IParticipant } from '@/types/event.model'; import { EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint'; import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
@ -263,6 +211,7 @@ import ParticipationModal from '@/components/Event/ParticipationModal.vue';
import { IReport } from '@/types/report.model'; import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report'; import { CREATE_REPORT } from '@/graphql/report';
import EventMixin from '@/mixins/event'; import EventMixin from '@/mixins/event';
import { EventRouteName } from '@/router/event';
@Component({ @Component({
components: { components: {
@ -283,6 +232,7 @@ import EventMixin from '@/mixins/event';
variables() { variables() {
return { return {
uuid: this.uuid, uuid: this.uuid,
roles: [ParticipantRole.CREATOR, ParticipantRole.MODERATOR, ParticipantRole.MODERATOR, ParticipantRole.PARTICIPANT].join(),
}; };
}, },
}, },
@ -302,6 +252,7 @@ export default class Event extends EventMixin {
isJoinModalActive: boolean = false; isJoinModalActive: boolean = false;
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
EventRouteName = EventRouteName;
/** /**
* Delete the event, then redirect to home. * Delete the event, then redirect to home.
@ -367,9 +318,10 @@ export default class Event extends EventMixin {
confirmLeave() { confirmLeave() {
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
title: `Leaving event « ${this.event.title} »`, title: this.$t('Leaving event "{title}"', { title: this.event.title }) as string,
message: `Are you sure you want to leave event « ${this.event.title} »`, message: this.$t('Are you sure you want to cancel your participation at event "{title}"?', { title: this.event.title }) as string,
confirmText: 'Leave event', confirmText: this.$t('Leave event') as string,
cancelText: this.$t('Cancel') as string,
type: 'is-danger', type: 'is-danger',
hasIcon: true, hasIcon: true,
onConfirm: () => this.leaveEvent(), onConfirm: () => this.leaveEvent(),

View File

@ -0,0 +1,197 @@
<template>
<main class="container">
<b-tabs type="is-boxed" v-if="event">
<b-tab-item>
<template slot="header">
<b-icon icon="information-outline"></b-icon>
<span> Participants <b-tag rounded> {{ participantStats.approved }} </b-tag> </span>
</template>
<section v-if="participantsAndCreators.length > 0">
<h2 class="title">{{ $t('Participants') }}</h2>
<div class="columns">
<div class="column is-one-quarter-desktop" v-for="participant in participantsAndCreators" :key="participant.actor.id">
<participant-card
:participant="participant"
:accept="acceptParticipant"
:reject="refuseParticipant"
:exclude="refuseParticipant"
/>
</div>
</div>
</section>
</b-tab-item>
<b-tab-item>
<template slot="header">
<b-icon icon="source-pull"></b-icon>
<span> Demandes <b-tag rounded> {{ participantStats.unapproved }} </b-tag> </span>
</template>
<section v-if="queue.length > 0">
<h2 class="title">{{ $t('Waiting list') }}</h2>
<div class="columns">
<div class="column is-one-quarter-desktop" v-for="participant in queue" :key="participant.actor.id">
<participant-card
:participant="participant"
:accept="acceptParticipant"
:reject="refuseParticipant"
:exclude="refuseParticipant"
/>
</div>
</div>
</section>
</b-tab-item>
</b-tabs>
</main>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent, IParticipant, Participant, ParticipantRole } from '@/types/event.model';
import { ACCEPT_PARTICIPANT, PARTICIPANTS, REJECT_PARTICIPANT } from '@/graphql/event';
import ParticipantCard from '@/components/Account/ParticipantCard.vue';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
@Component({
components: {
ParticipantCard,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
event: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: 1,
limit: 10,
roles: [ParticipantRole.PARTICIPANT].join(),
};
},
},
organizers: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: 1,
limit: 20,
roles: [ParticipantRole.CREATOR].join(),
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),
},
queue: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: 1,
limit: 20,
roles: [ParticipantRole.NOT_APPROVED].join(),
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),
},
},
})
export default class Participants extends Vue {
@Prop({ required: true }) eventId!: string;
page: number = 1;
limit: number = 10;
// participants: IParticipant[] = [];
organizers: IParticipant[] = [];
queue: IParticipant[] = [];
event!: IEvent;
ParticipantRole = ParticipantRole;
currentActor!: IPerson;
hasMoreParticipants: boolean = false;
get participants(): IParticipant[] {
return this.event.participants.map(participant => new Participant(participant));
}
get participantStats(): Object {
return this.event.participantStats;
}
get participantsAndCreators(): IParticipant[] {
if (this.event) {
return [...this.organizers, ...this.participants];
}
return [];
}
loadMoreParticipants() {
this.page += 1;
this.$apollo.queries.participants.fetchMore({
// New variables
variables: {
page: this.page,
limit: this.limit,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newParticipations = fetchMoreResult.event.participants;
this.hasMoreParticipants = newParticipations.length === this.limit;
return {
loggedUser: {
__typename: previousResult.event.__typename,
participations: [...previousResult.event.participants, ...newParticipations],
},
};
},
});
}
async acceptParticipant(participant: IParticipant) {
try {
const { data } = await this.$apollo.mutate({
mutation: ACCEPT_PARTICIPANT,
variables: {
id: participant.id,
moderatorActorId: this.currentActor.id,
},
});
if (data) {
console.log('accept', data);
this.queue.filter(participant => participant !== data.acceptParticipation.id);
this.participants.push(participant);
}
} catch (e) {
console.error(e);
}
}
async refuseParticipant(participant: IParticipant) {
try {
const { data } = await this.$apollo.mutate({
mutation: REJECT_PARTICIPANT,
variables: {
id: participant.id,
moderatorActorId: this.currentActor.id,
},
});
if (data) {
this.participants.filter(participant => participant !== data.rejectParticipation.id);
this.queue.filter(participant => participant !== data.rejectParticipation.id);
}
} catch (e) {
console.error(e);
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
section {
padding: 3rem 0;
}
</style>

View File

@ -107,7 +107,7 @@ export default class Group extends Vue {
} }
} }
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
section.container { section.container {
min-height: 30em; min-height: 30em;
} }

View File

@ -32,12 +32,13 @@
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t('Group') }}</router-link> <router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t('Group') }}</router-link>
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
<section v-if="currentActor" class="container"> <section v-if="currentActor && goingToEvents.size > 0" class="container">
<h3 class="title"> <h3 class="title">
{{ $t("Upcoming") }} {{ $t("Upcoming") }}
</h3> </h3>
<pre>{{ Array.from(goingToEvents.entries()) }}</pre>
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="goingToEvents.size > 0" v-for="row in goingToEvents" class="upcoming-events"> <div v-for="row in goingToEvents" class="upcoming-events">
<span class="date-component-container" v-if="isInLessThanSevenDays(row[0])"> <span class="date-component-container" v-if="isInLessThanSevenDays(row[0])">
<date-component :date="row[0]"></date-component> <date-component :date="row[0]"></date-component>
<h3 class="subtitle" <h3 class="subtitle"
@ -63,9 +64,6 @@
/> />
</div> </div>
</div> </div>
<b-message v-else type="is-danger">
{{ $t("You're not going to any event yet") }}
</b-message>
<span class="view-all"> <span class="view-all">
<router-link :to=" { name: EventRouteName.MY_EVENTS }">{{ $t('View everything')}} >></router-link> <router-link :to=" { name: EventRouteName.MY_EVENTS }">{{ $t('View everything')}} >></router-link>
</span> </span>
@ -78,9 +76,10 @@
<div class="level"> <div class="level">
<EventListCard <EventListCard
v-for="participation in lastWeekEvents" v-for="participation in lastWeekEvents"
:key="participation.event.uuid" :key="participation.id"
:participation="participation" :participation="participation"
class="level-item" class="level-item"
:options="{ hideDate: false }"
/> />
</div> </div>
</section> </section>
@ -190,6 +189,10 @@ export default class Home extends Vue {
return this.calculateDiffDays(date) < nbDays; return this.calculateDiffDays(date) < nbDays;
} }
isAfter(date: string, nbDays: number) :boolean {
return this.calculateDiffDays(date) >= nbDays;
}
isInLessThanSevenDays(date: string): boolean { isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7); return this.isBefore(date, 7);
} }
@ -200,7 +203,7 @@ export default class Home extends Vue {
get goingToEvents(): Map<string, Map<string, IParticipant>> { get goingToEvents(): Map<string, Map<string, IParticipant>> {
const res = this.currentUserParticipations.filter(({ event }) => { const res = this.currentUserParticipations.filter(({ event }) => {
return event.beginsOn != null && !this.isBefore(event.beginsOn.toDateString(), 0); return event.beginsOn != null && this.isAfter(event.beginsOn.toDateString(), 0) && this.isBefore(event.beginsOn.toDateString(), 7);
}); });
res.sort( res.sort(
(a: IParticipant, b: IParticipant) => a.event.beginsOn.getTime() - b.event.beginsOn.getTime(), (a: IParticipant, b: IParticipant) => a.event.beginsOn.getTime() - b.event.beginsOn.getTime(),
@ -208,7 +211,7 @@ export default class Home extends Vue {
return res.reduce((acc: Map<string, Map<string, IParticipant>>, participation: IParticipant) => { return res.reduce((acc: Map<string, Map<string, IParticipant>>, participation: IParticipant) => {
const day = (new Date(participation.event.beginsOn)).toDateString(); const day = (new Date(participation.event.beginsOn)).toDateString();
const participations: Map<string, IParticipant> = acc.get(day) || new Map(); const participations: Map<string, IParticipant> = acc.get(day) || new Map();
participations.set(participation.event.uuid, participation); participations.set(`${participation.event.uuid}${participation.actor.id}`, participation);
acc.set(day, participations); acc.set(day, participations);
return acc; return acc;
}, new Map()); }, new Map());
@ -273,7 +276,7 @@ export default class Home extends Vue {
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only --> <!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss"> <style lang="scss" scoped>
.search-autocomplete { .search-autocomplete {
border: 1px solid #dbdbdb; border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, 0.87);

View File

@ -67,6 +67,7 @@ defmodule Mobilizon.Events.Event do
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@update_required_attrs @required_attrs @update_required_attrs @required_attrs
@update_optional_attrs [ @update_optional_attrs [
:slug, :slug,
:description, :description,
@ -74,6 +75,7 @@ defmodule Mobilizon.Events.Event do
:category, :category,
:status, :status,
:visibility, :visibility,
:join_options,
:publish_at, :publish_at,
:online_address, :online_address,
:phone_address, :phone_address,

View File

@ -522,6 +522,26 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Gets a single participant. Gets a single participant.
## Examples
iex> get_participant(123)
%Participant{}
iex> get_participant(456)
nil
"""
@spec get_participant(integer) :: Participant.t()
def get_participant(participant_id) do
Participant
|> where([p], p.id == ^participant_id)
|> preload([p], [:event, :actor])
|> Repo.one()
end
@doc """
Gets a single participation for an event and actor.
""" """
@spec get_participant(integer | String.t(), integer | String.t()) :: @spec get_participant(integer | String.t(), integer | String.t()) ::
{:ok, Participant.t()} | {:error, :participant_not_found} {:ok, Participant.t()} | {:error, :participant_not_found}
@ -536,8 +556,18 @@ defmodule Mobilizon.Events do
end end
@doc """ @doc """
Gets a single participant. Gets a single participation for an event and actor.
Raises `Ecto.NoResultsError` if the participant does not exist.
Raises `Ecto.NoResultsError` if the Participant does not exist.
## Examples
iex> get_participant!(123, 19)
%Participant{}
iex> get_participant!(456, 5)
** (Ecto.NoResultsError)
""" """
@spec get_participant!(integer | String.t(), integer | String.t()) :: Participant.t() @spec get_participant!(integer | String.t(), integer | String.t()) :: Participant.t()
def get_participant!(event_id, actor_id) do def get_participant!(event_id, actor_id) do
@ -554,35 +584,20 @@ defmodule Mobilizon.Events do
|> Repo.one() |> Repo.one()
end end
@doc """ @default_participant_roles [:participant, :moderator, :administrator, :creator]
Gets the default participant role depending on the event join options.
"""
@spec get_default_participant_role(Event.t()) :: :participant | :not_approved
def get_default_participant_role(%Event{join_options: :free}), do: :participant
def get_default_participant_role(%Event{join_options: _}), do: :not_approved
@doc """ @doc """
Creates a participant. Returns the list of participants for an event.
Default behaviour is to not return :not_approved participants
""" """
@spec create_participant(map) :: {:ok, Participant.t()} | {:error, Ecto.Changeset.t()} @spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
def create_participant(attrs \\ %{}) do [Participant.t()]
with {:ok, %Participant{} = participant} <- def list_participants_for_event(uuid, roles \\ @default_participant_roles, page, limit) do
%Participant{} uuid
|> Participant.changeset(attrs) |> list_participants_for_event_query()
|> Repo.insert() do |> filter_role(roles)
{:ok, Repo.preload(participant, [:event, :actor])} |> Page.paginate(page, limit)
end |> Repo.all()
end
@doc """
Updates a participant.
"""
@spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def update_participant(%Participant{} = participant, attrs) do
participant
|> Participant.changeset(attrs)
|> Repo.update()
end end
@doc """ @doc """
@ -596,84 +611,43 @@ defmodule Mobilizon.Events do
[%Participant{}, ...] [%Participant{}, ...]
""" """
def list_participations_for_user( @spec list_participations_for_user(
user_id, integer,
after_datetime \\ nil, DateTime.t() | nil,
before_datetime \\ nil, DateTime.t() | nil,
page \\ nil, integer | nil,
limit \\ nil integer | nil
) ) :: list(Participant.t())
def list_participations_for_user(user_id, after_datetime, before_datetime, page, limit) do
def list_participations_for_user(user_id, %DateTime{} = after_datetime, nil, page, limit) do
user_id user_id
|> do_list_participations_for_user(page, limit) |> list_participations_for_user_query()
|> where([_p, e, _a], e.begins_on > ^after_datetime) |> participation_filter_begins_on(after_datetime, before_datetime)
|> order_by([_p, e, _a], asc: e.begins_on)
|> Repo.all()
end
def list_participations_for_user(user_id, nil, %DateTime{} = before_datetime, page, limit) do
user_id
|> do_list_participations_for_user(page, limit)
|> where([_p, e, _a], e.begins_on < ^before_datetime)
|> order_by([_p, e, _a], desc: e.begins_on)
|> Repo.all()
end
def list_participations_for_user(user_id, nil, nil, page, limit) do
user_id
|> do_list_participations_for_user(page, limit)
|> order_by([_p, e, _a], desc: e.begins_on)
|> Repo.all()
end
defp do_list_participations_for_user(user_id, page, limit) do
from(
p in Participant,
join: e in Event,
join: a in Actor,
on: p.actor_id == a.id,
on: p.event_id == e.id,
where: a.user_id == ^user_id and p.role != ^:not_approved,
preload: [:event, :actor]
)
|> Page.paginate(page, limit)
end
@doc """
Deletes a participant.
"""
@spec delete_participant(Participant.t()) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def delete_participant(%Participant{} = participant), do: Repo.delete(participant)
@doc """
Returns the list of participants.
"""
@spec list_participants :: [Participant.t()]
def list_participants, do: Repo.all(Participant)
@doc """
Returns the list of participants for an event.
Default behaviour is to not return :not_approved participants
"""
@spec list_participants_for_event(String.t(), integer | nil, integer | nil, boolean) ::
[Participant.t()]
def list_participants_for_event(
event_uuid,
page \\ nil,
limit \\ nil,
include_not_improved \\ false
)
def list_participants_for_event(event_uuid, page, limit, include_not_improved) do
event_uuid
|> participants_for_event()
|> filter_role(include_not_improved)
|> Page.paginate(page, limit) |> Page.paginate(page, limit)
|> Repo.all() |> Repo.all()
end end
@doc """
Returns the list of moderator participants for an event.
## Examples
iex> moderator_for_event?(5, 3)
true
"""
@spec moderator_for_event?(integer, integer) :: boolean
def moderator_for_event?(event_id, actor_id) do
!(Repo.one(
from(
p in Participant,
where:
p.event_id == ^event_id and
p.actor_id ==
^actor_id and p.role in ^[:moderator, :administrator, :creator]
)
) == nil)
end
@doc """ @doc """
Returns the list of organizers participants for an event. Returns the list of organizers participants for an event.
@ -739,6 +713,44 @@ defmodule Mobilizon.Events do
|> Repo.aggregate(:count, :id) |> Repo.aggregate(:count, :id)
end end
@doc """
Gets the default participant role depending on the event join options.
"""
@spec get_default_participant_role(Event.t()) :: :participant | :not_approved
def get_default_participant_role(%Event{join_options: :free}), do: :participant
def get_default_participant_role(%Event{join_options: _}), do: :not_approved
@doc """
Creates a participant.
"""
@spec create_participant(map) :: {:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def create_participant(attrs \\ %{}) do
with {:ok, %Participant{} = participant} <-
%Participant{}
|> Participant.changeset(attrs)
|> Repo.insert() do
{:ok, Repo.preload(participant, [:event, :actor])}
end
end
@doc """
Updates a participant.
"""
@spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def update_participant(%Participant{} = participant, attrs) do
participant
|> Participant.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a participant.
"""
@spec delete_participant(Participant.t()) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def delete_participant(%Participant{} = participant), do: Repo.delete(participant)
@doc """ @doc """
Gets a single session. Gets a single session.
Raises `Ecto.NoResultsError` if the session does not exist. Raises `Ecto.NoResultsError` if the session does not exist.
@ -1203,17 +1215,6 @@ defmodule Mobilizon.Events do
) )
end end
@spec participants_for_event(String.t()) :: Ecto.Query.t()
defp participants_for_event(event_uuid) do
from(
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: e.uuid == ^event_uuid,
preload: [:actor]
)
end
defp organizers_participants_for_event(event_id) do defp organizers_participants_for_event(event_id) do
from( from(
p in Participant, p in Participant,
@ -1274,6 +1275,30 @@ defmodule Mobilizon.Events do
) )
end end
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
defp list_participants_for_event_query(event_uuid) do
from(
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: e.uuid == ^event_uuid,
preload: [:actor]
)
end
@spec list_participations_for_user_query(integer()) :: Ecto.Query.t()
defp list_participations_for_user_query(user_id) do
from(
p in Participant,
join: e in Event,
join: a in Actor,
on: p.actor_id == a.id,
on: p.event_id == e.id,
where: a.user_id == ^user_id and p.role != ^:not_approved,
preload: [:event, :actor]
)
end
@spec count_comments_query(integer) :: Ecto.Query.t() @spec count_comments_query(integer) :: Ecto.Query.t()
defp count_comments_query(actor_id) do defp count_comments_query(actor_id) do
from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id) from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)
@ -1341,9 +1366,33 @@ defmodule Mobilizon.Events do
from(p in query, where: p.role == ^:not_approved) from(p in query, where: p.role == ^:not_approved)
end end
@spec filter_role(Ecto.Query.t(), boolean) :: Ecto.Query.t() @spec filter_role(Ecto.Query.t(), list(atom())) :: Ecto.Query.t()
defp filter_role(query, false), do: filter_approved_role(query) defp filter_role(query, []), do: query
defp filter_role(query, true), do: query
defp filter_role(query, roles) do
where(query, [p], p.role in ^roles)
end
defp participation_filter_begins_on(query, nil, nil),
do: participation_order_begins_on_desc(query)
defp participation_filter_begins_on(query, %DateTime{} = after_datetime, nil) do
query
|> where([_p, e, _a], e.begins_on > ^after_datetime)
|> participation_order_begins_on_asc()
end
defp participation_filter_begins_on(query, nil, %DateTime{} = before_datetime) do
query
|> where([_p, e, _a], e.begins_on < ^before_datetime)
|> participation_order_begins_on_desc()
end
defp participation_order_begins_on_asc(query),
do: order_by(query, [_p, e, _a], asc: e.begins_on)
defp participation_order_begins_on_desc(query),
do: order_by(query, [_p, e, _a], desc: e.begins_on)
@spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t() @spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_event(query), do: preload(query, ^@event_preloads) defp preload_for_event(query), do: preload(query, ^@event_preloads)

View File

@ -24,6 +24,7 @@ defmodule MobilizonWeb.API.Events do
begins_on: begins_on, begins_on: begins_on,
ends_on: ends_on, ends_on: ends_on,
category: category, category: category,
join_options: join_options,
options: options options: options
} <- prepare_args(args), } <- prepare_args(args),
event <- event <-
@ -39,7 +40,8 @@ defmodule MobilizonWeb.API.Events do
ends_on: ends_on, ends_on: ends_on,
physical_address: physical_address, physical_address: physical_address,
category: category, category: category,
options: options options: options,
join_options: join_options
} }
) do ) do
ActivityPub.create(%{ ActivityPub.create(%{
@ -73,6 +75,7 @@ defmodule MobilizonWeb.API.Events do
begins_on: begins_on, begins_on: begins_on,
ends_on: ends_on, ends_on: ends_on,
category: category, category: category,
join_options: join_options,
options: options options: options
} <- } <-
prepare_args(Map.merge(event, args)), prepare_args(Map.merge(event, args)),
@ -89,6 +92,7 @@ defmodule MobilizonWeb.API.Events do
ends_on: ends_on, ends_on: ends_on,
physical_address: physical_address, physical_address: physical_address,
category: category, category: category,
join_options: join_options,
options: options options: options
}, },
event.uuid, event.uuid,
@ -112,7 +116,8 @@ defmodule MobilizonWeb.API.Events do
options: options, options: options,
tags: tags, tags: tags,
begins_on: begins_on, begins_on: begins_on,
category: category category: category,
join_options: join_options
} = args } = args
) do ) do
with physical_address <- Map.get(args, :physical_address, nil), with physical_address <- Map.get(args, :physical_address, nil),
@ -132,6 +137,7 @@ defmodule MobilizonWeb.API.Events do
begins_on: begins_on, begins_on: begins_on,
ends_on: Map.get(args, :ends_on, nil), ends_on: Map.get(args, :ends_on, nil),
category: category, category: category,
join_options: join_options,
options: options options: options
} }
end end

View File

@ -4,9 +4,9 @@ defmodule MobilizonWeb.API.Participations do
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
require Logger
@spec join(Event.t(), Actor.t()) :: {:ok, Participant.t()} @spec join(Event.t(), Actor.t()) :: {:ok, Participant.t()}
def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor) do def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor) do
@ -21,4 +21,42 @@ defmodule MobilizonWeb.API.Participations do
{:ok, activity, participant} {:ok, activity, participant}
end end
end end
def accept(
%Participant{} = participation,
%Actor{} = moderator
) do
with {:ok, activity, _} <-
ActivityPub.accept(
%{
to: [participation.actor.url],
actor: moderator.url,
object: participation.url
},
"#{MobilizonWeb.Endpoint.url()}/accept/join/#{participation.id}"
),
{:ok, %Participant{role: :participant} = participation} <-
Events.update_participant(participation, %{"role" => :participant}) do
{:ok, activity, participation}
end
end
def reject(
%Participant{} = participation,
%Actor{} = moderator
) do
with {:ok, activity, _} <-
ActivityPub.reject(
%{
to: [participation.actor.url],
actor: moderator.url,
object: participation.url
},
"#{MobilizonWeb.Endpoint.url()}/reject/join/#{participation.id}"
),
{:ok, %Participant{} = participation} <-
Events.delete_participant(participation) do
{:ok, activity, participation}
end
end
end end

View File

@ -42,14 +42,30 @@ defmodule MobilizonWeb.Resolvers.Event do
List participant for event (separate request) List participant for event (separate request)
""" """
def list_participants_for_event(_parent, %{uuid: uuid, page: page, limit: limit}, _resolution) do def list_participants_for_event(_parent, %{uuid: uuid, page: page, limit: limit}, _resolution) do
{:ok, Mobilizon.Events.list_participants_for_event(uuid, page, limit)} {:ok, Mobilizon.Events.list_participants_for_event(uuid, [], page, limit)}
end end
@doc """ @doc """
List participants for event (through an event request) List participants for event (through an event request)
""" """
def list_participants_for_event(%Event{uuid: uuid}, _args, _resolution) do def list_participants_for_event(
{:ok, Mobilizon.Events.list_participants_for_event(uuid, 1, 10)} %Event{uuid: uuid},
%{page: page, limit: limit, roles: roles},
_resolution
) do
roles =
case roles do
"" ->
[]
roles ->
roles
|> String.split(",")
|> Enum.map(&String.downcase/1)
|> Enum.map(&String.to_existing_atom/1)
end
{:ok, Mobilizon.Events.list_participants_for_event(uuid, roles, page, limit)}
end end
def stats_participants_for_event(%Event{id: id}, _args, _resolution) do def stats_participants_for_event(%Event{id: id}, _args, _resolution) do
@ -175,6 +191,87 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error, "You need to be logged-in to leave an event"} {:error, "You need to be logged-in to leave an event"}
end end
def accept_participation(
_parent,
%{id: participation_id, moderator_actor_id: moderator_actor_id},
%{
context: %{
current_user: user
}
}
) do
# Check that moderator provided is rightly authenticated
with {:is_owned, true, moderator_actor} <- User.owns_actor(user, moderator_actor_id),
# Check that participation already exists
{:has_participation, %Participant{role: :not_approved} = participation} <-
{:has_participation, Mobilizon.Events.get_participant(participation_id)},
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission,
Mobilizon.Events.moderator_for_event?(participation.event.id, moderator_actor_id)},
{:ok, _activity, participation} <-
MobilizonWeb.API.Participations.accept(participation, moderator_actor) do
{:ok, participation}
else
{:is_owned, false} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
{:has_participation, %Participant{role: role, id: id}} ->
{:error,
"Participant #{id} can't be approved since it's already a participant (with role #{role})"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
def reject_participation(
_parent,
%{id: participation_id, moderator_actor_id: moderator_actor_id},
%{
context: %{
current_user: user
}
}
) do
# Check that moderator provided is rightly authenticated
with {:is_owned, true, moderator_actor} <- User.owns_actor(user, moderator_actor_id),
# Check that participation really exists
{:has_participation, %Participant{} = participation} <-
{:has_participation, Mobilizon.Events.get_participant(participation_id)},
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission,
Mobilizon.Events.moderator_for_event?(participation.event.id, moderator_actor_id)},
{:ok, _activity, participation} <-
MobilizonWeb.API.Participations.reject(participation, moderator_actor) do
{
:ok,
%{
id: participation.id,
event: %{
id: participation.event.id
},
actor: %{
id: participation.actor.id
}
}
}
else
{:is_owned, false} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}
{:has_participation, nil} ->
{:error, "Participant not found"}
end
end
@doc """ @doc """
Create an event Create an event
""" """

View File

@ -23,7 +23,8 @@ defmodule MobilizonWeb.Schema.EventType do
field(:begins_on, :datetime, description: "Datetime for when the event begins") field(:begins_on, :datetime, description: "Datetime for when the event begins")
field(:ends_on, :datetime, description: "Datetime for when the event ends") field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:status, :event_status, description: "Status of the event") field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility") field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility")
field(:picture, :picture, field(:picture, :picture,
description: "The event's picture", description: "The event's picture",
@ -56,10 +57,12 @@ defmodule MobilizonWeb.Schema.EventType do
field(:participant_stats, :participant_stats, resolve: &Event.stats_participants_for_event/3) field(:participant_stats, :participant_stats, resolve: &Event.stats_participants_for_event/3)
field(:participants, list_of(:participant), field(:participants, list_of(:participant), description: "The event's participants") do
resolve: &Event.list_participants_for_event/3, arg(:page, :integer, default_value: 1)
description: "The event's participants" arg(:limit, :integer, default_value: 10)
) arg(:roles, :string, default_value: "")
resolve(&Event.list_participants_for_event/3)
end
field(:related_events, list_of(:event), field(:related_events, list_of(:event),
resolve: &Event.list_related_events/3, resolve: &Event.list_related_events/3,
@ -78,13 +81,18 @@ defmodule MobilizonWeb.Schema.EventType do
enum :event_visibility do enum :event_visibility do
value(:public, description: "Publicly listed and federated. Can be shared.") value(:public, description: "Publicly listed and federated. Can be shared.")
value(:unlisted, description: "Visible only to people with the link - or invited") value(:unlisted, description: "Visible only to people with the link - or invited")
value(:restricted, description: "Visible only after a moderator accepted")
value(:private, value(:private,
description: "Visible only to people members of the group or followers of the person" description: "Visible only to people members of the group or followers of the person"
) )
end
value(:moderated, description: "Visible only after a moderator accepted") @desc "The list of join options for an event"
value(:invite, description: "visible only to people invited") enum :event_join_options do
value(:free, description: "Anyone can join and is automatically accepted")
value(:restricted, description: "Manual acceptation")
value(:invite, description: "Participants must be invited")
end end
@desc "The list of possible options for the event's status" @desc "The list of possible options for the event's status"
@ -218,6 +226,7 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:ends_on, :datetime) arg(:ends_on, :datetime)
arg(:status, :event_status) arg(:status, :event_status)
arg(:visibility, :event_visibility, default_value: :private) arg(:visibility, :event_visibility, default_value: :private)
arg(:join_options, :event_join_options, default_value: :free)
arg(:tags, list_of(:string), arg(:tags, list_of(:string),
default_value: [], default_value: [],
@ -250,6 +259,7 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:ends_on, :datetime) arg(:ends_on, :datetime)
arg(:status, :event_status) arg(:status, :event_status)
arg(:visibility, :event_visibility) arg(:visibility, :event_visibility)
arg(:join_options, :event_join_options)
arg(:tags, list_of(:string), description: "The list of tags associated to the event") arg(:tags, list_of(:string), description: "The list of tags associated to the event")

View File

@ -10,6 +10,8 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
@desc "Represents a participant to an event" @desc "Represents a participant to an event"
object :participant do object :participant do
field(:id, :id, description: "The participation ID")
field( field(
:event, :event,
:event, :event,
@ -24,11 +26,20 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
description: "The actor that participates to the event" description: "The actor that participates to the event"
) )
field(:role, :integer, description: "The role of this actor at this event") field(:role, :participant_role_enum, description: "The role of this actor at this event")
end
enum :participant_role_enum do
value(:not_approved)
value(:participant)
value(:moderator)
value(:administrator)
value(:creator)
end end
@desc "Represents a deleted participant" @desc "Represents a deleted participant"
object :deleted_participant do object :deleted_participant do
field(:id, :id)
field(:event, :deleted_object) field(:event, :deleted_object)
field(:actor, :deleted_object) field(:actor, :deleted_object)
end end
@ -59,5 +70,21 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
resolve(&Resolvers.Event.actor_leave_event/3) resolve(&Resolvers.Event.actor_leave_event/3)
end end
@desc "Accept a participation"
field :accept_participation, :participant do
arg(:id, non_null(:id))
arg(:moderator_actor_id, non_null(:id))
resolve(&Resolvers.Event.accept_participation/3)
end
@desc "Reject a participation"
field :reject_participation, :deleted_participant do
arg(:id, non_null(:id))
arg(:moderator_actor_id, non_null(:id))
resolve(&Resolvers.Event.reject_participation/3)
end
end end
end end

View File

@ -59,6 +59,7 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
"begins_on" => object["startTime"], "begins_on" => object["startTime"],
"ends_on" => object["endTime"], "ends_on" => object["endTime"],
"category" => object["category"], "category" => object["category"],
"join_options" => object["joinOptions"],
"url" => object["id"], "url" => object["id"],
"uuid" => object["uuid"], "uuid" => object["uuid"],
"tags" => tags, "tags" => tags,

View File

@ -328,6 +328,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"category" => metadata.category, "category" => metadata.category,
"actor" => actor, "actor" => actor,
"id" => url || Routes.page_url(Endpoint, :event, uuid), "id" => url || Routes.page_url(Endpoint, :event, uuid),
"joinOptions" => metadata.join_options,
"uuid" => uuid, "uuid" => uuid,
"tag" => "tag" =>
tags |> Enum.uniq() |> Enum.map(fn tag -> %{"type" => "Hashtag", "name" => "##{tag}"} end) tags |> Enum.uniq() |> Enum.map(fn tag -> %{"type" => "Hashtag", "name" => "##{tag}"} end)

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api # source: http://localhost:4000/api
# timestamp: Wed Sep 18 2019 17:12:13 GMT+0200 (GMT+02:00) # timestamp: Fri Sep 20 2019 16:55:10 GMT+0200 (GMT+02:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -244,6 +244,7 @@ type DeletedObject {
type DeletedParticipant { type DeletedParticipant {
actor: DeletedObject actor: DeletedObject
event: DeletedObject event: DeletedObject
id: ID
} }
"""An event""" """An event"""
@ -269,6 +270,9 @@ type Event implements ActionLogObject {
"""Internal ID for this event""" """Internal ID for this event"""
id: ID id: ID
"""The event's visibility"""
joinOptions: EventJoinOptions
"""Whether the event is local or not""" """Whether the event is local or not"""
local: Boolean local: Boolean
@ -283,7 +287,7 @@ type Event implements ActionLogObject {
participantStats: ParticipantStats participantStats: ParticipantStats
"""The event's participants""" """The event's participants"""
participants: [Participant] participants(limit: Int = 10, page: Int = 1, roles: String = ""): [Participant]
"""Phone address for the event""" """Phone address for the event"""
phoneAddress: String phoneAddress: String
@ -321,7 +325,7 @@ type Event implements ActionLogObject {
"""The Event UUID""" """The Event UUID"""
uuid: UUID uuid: UUID
"""The event's visibility""" """The event's visibility"""
visibility: EventVisibility visibility: EventVisibility
} }
@ -337,6 +341,18 @@ enum EventCommentModeration {
MODERATED MODERATED
} }
"""The list of join options for an event"""
enum EventJoinOptions {
"""Anyone can join and is automatically accepted"""
FREE
"""Participants must be invited"""
INVITE
"""Manual acceptation"""
RESTRICTED
}
type EventOffer { type EventOffer {
"""The price amount for this offer""" """The price amount for this offer"""
price: Float price: Float
@ -462,18 +478,15 @@ enum EventStatus {
"""The list of visibility options for an event""" """The list of visibility options for an event"""
enum EventVisibility { enum EventVisibility {
"""visible only to people invited"""
INVITE
"""Visible only after a moderator accepted"""
MODERATED
"""Visible only to people members of the group or followers of the person""" """Visible only to people members of the group or followers of the person"""
PRIVATE PRIVATE
"""Publicly listed and federated. Can be shared.""" """Publicly listed and federated. Can be shared."""
PUBLIC PUBLIC
"""Visible only after a moderator accepted"""
RESTRICTED
"""Visible only to people with the link - or invited""" """Visible only to people with the link - or invited"""
UNLISTED UNLISTED
} }
@ -645,8 +658,19 @@ type Participant {
"""The event which the actor participates in""" """The event which the actor participates in"""
event: Event event: Event
"""The participation ID"""
id: ID
"""The role of this actor at this event""" """The role of this actor at this event"""
role: Int role: ParticipantRoleEnum
}
enum ParticipantRoleEnum {
ADMINISTRATOR
CREATOR
MODERATOR
NOT_APPROVED
PARTICIPANT
} }
type ParticipantStats { type ParticipantStats {
@ -855,6 +879,9 @@ enum ReportStatus {
} }
type RootMutationType { type RootMutationType {
"""Accept a participation"""
acceptParticipation(id: ID!, moderatorActorId: ID!): Participant
"""Change default actor for user""" """Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User changeDefaultActor(preferredUsername: String!): User
@ -867,6 +894,7 @@ type RootMutationType {
category: String = "meeting" category: String = "meeting"
description: String! description: String!
endsOn: DateTime endsOn: DateTime
joinOptions: EventJoinOptions = FREE
onlineAddress: String onlineAddress: String
options: EventOptionsInput options: EventOptionsInput
organizerActorId: ID! organizerActorId: ID!
@ -997,6 +1025,9 @@ type RootMutationType {
summary: String = "" summary: String = ""
): Person ): Person
"""Reject a participation"""
rejectParticipation(id: ID!, moderatorActorId: ID!): DeletedParticipant
"""Resend registration confirmation token""" """Resend registration confirmation token"""
resendConfirmationEmail(email: String!, locale: String = "en"): String resendConfirmationEmail(email: String!, locale: String = "en"): String
@ -1013,6 +1044,7 @@ type RootMutationType {
description: String description: String
endsOn: DateTime endsOn: DateTime
eventId: ID! eventId: ID!
joinOptions: EventJoinOptions
onlineAddress: String onlineAddress: String
options: EventOptionsInput options: EventOptionsInput
phoneAddress: String phoneAddress: String

View File

@ -316,13 +316,6 @@ defmodule Mobilizon.EventsTest do
{:ok, participant: participant, event: event, actor: actor} {:ok, participant: participant, event: event, actor: actor}
end end
test "list_participants/0 returns all participants", %{
participant: %Participant{event_id: participant_event_id, actor_id: participant_actor_id}
} do
assert [participant_event_id] == Events.list_participants() |> Enum.map(& &1.event_id)
assert [participant_actor_id] == Events.list_participants() |> Enum.map(& &1.actor_id)
end
test "get_participant!/1 returns the participant for a given event and given actor", %{ test "get_participant!/1 returns the participant for a given event and given actor", %{
event: %Event{id: event_id}, event: %Event{id: event_id},
actor: %Actor{id: actor_id} actor: %Actor{id: actor_id}

View File

@ -784,7 +784,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do
assert :error == Transmogrifier.handle_incoming(reject_data) assert :error == Transmogrifier.handle_incoming(reject_data)
# Organiser is not present since we use factories directly # Organiser is not present since we use factories directly
assert Events.list_participants_for_event(event.uuid, 1, 10, true) |> Enum.map(& &1.id) == assert Events.list_participants_for_event(event.uuid) |> Enum.map(& &1.id) ==
[] []
end end
@ -812,7 +812,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do
assert activity.data["actor"] == participant_url assert activity.data["actor"] == participant_url
# The only participant left is the organizer # The only participant left is the organizer
assert Events.list_participants_for_event(event.uuid, 1, 10, true) |> Enum.map(& &1.id) == [ assert Events.list_participants_for_event(event.uuid) |> Enum.map(& &1.id) == [
organizer_participation.id organizer_participation.id
] ]
end end

View File

@ -50,7 +50,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "participant" assert json_response(res, 200)["data"]["joinEvent"]["role"] == "PARTICIPANT"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.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) assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
@ -161,10 +161,12 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
query = """ query = """
{ {
participants(uuid: "#{event.uuid}") { event(uuid: "#{event.uuid}") {
role, participants {
actor { role,
preferredUsername actor {
preferredUsername
}
} }
} }
} }
@ -174,12 +176,12 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
assert json_response(res, 200)["data"]["participants"] == [ assert json_response(res, 200)["data"]["event"]["participants"] == [
%{ %{
"actor" => %{ "actor" => %{
"preferredUsername" => participant2.actor.preferred_username "preferredUsername" => participant2.actor.preferred_username
}, },
"role" => "creator" "role" => "CREATOR"
} }
] ]
end end
@ -331,10 +333,12 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
query = """ query = """
{ {
participants(uuid: "#{event.uuid}") { event(uuid: "#{event.uuid}") {
role, participants(roles: "participant,moderator,administrator,creator") {
actor { role,
preferredUsername actor {
preferredUsername
}
} }
} }
} }
@ -344,12 +348,12 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
context.conn context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
assert json_response(res, 200)["data"]["participants"] == [ assert json_response(res, 200)["data"]["event"]["participants"] == [
%{ %{
"actor" => %{ "actor" => %{
"preferredUsername" => context.actor.preferred_username "preferredUsername" => context.actor.preferred_username
}, },
"role" => "creator" "role" => "CREATOR"
} }
] ]
@ -361,12 +365,59 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
# This one will (as a participant) # This one will (as a participant)
participant2 = insert(:participant, event: event, actor: actor3, role: :participant) participant2 = insert(:participant, event: event, actor: actor3, role: :participant)
query = """
{
event(uuid: "#{event.uuid}") {
participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator") {
role,
actor {
preferredUsername
}
}
}
}
"""
res = res =
context.conn context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants = sorted_participants =
json_response(res, 200)["data"]["participants"] json_response(res, 200)["data"]["event"]["participants"]
|> Enum.sort_by(
&(&1
|> Map.get("actor")
|> Map.get("preferredUsername"))
)
assert sorted_participants == [
%{
"actor" => %{
"preferredUsername" => participant2.actor.preferred_username
},
"role" => "PARTICIPANT"
}
]
query = """
{
event(uuid: "#{event.uuid}") {
participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator") {
role,
actor {
preferredUsername
}
}
}
}
"""
res =
context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants =
json_response(res, 200)["data"]["event"]["participants"]
|> Enum.sort_by( |> Enum.sort_by(
&(&1 &(&1
|> Map.get("actor") |> Map.get("actor")
@ -378,13 +429,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
"actor" => %{ "actor" => %{
"preferredUsername" => context.actor.preferred_username "preferredUsername" => context.actor.preferred_username
}, },
"role" => "creator" "role" => "CREATOR"
},
%{
"actor" => %{
"preferredUsername" => participant2.actor.preferred_username
},
"role" => "participant"
} }
] ]
end end
@ -456,4 +501,281 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert json_response(res, 200)["data"]["event"]["participantStats"]["unapproved"] == 1 assert json_response(res, 200)["data"]["event"]["participantStats"]["unapproved"] == 1
end end
end end
describe "Participant approval" do
test "accept_participation/3", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted, organizer_actor: actor_creator)
insert(:participant, event: event, actor: actor_creator, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
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)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
acceptParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["acceptParticipation"]["role"] == "PARTICIPANT"
assert json_response(res, 200)["data"]["acceptParticipation"]["event"]["id"] ==
to_string(event.id)
assert json_response(res, 200)["data"]["acceptParticipation"]["actor"]["id"] ==
to_string(actor.id)
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] =~
" can't be approved since it's already a participant (with role participant)"
end
test "accept_participation/3 with bad parameters", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted)
insert(:participant, event: event, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
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)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
acceptParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"Provided moderator actor ID doesn't have permission on this event"
end
end
describe "reject participation" do
test "reject_participation/3", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted, organizer_actor: actor_creator)
insert(:participant, event: event, actor: actor_creator, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
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)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
rejectParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["rejectParticipation"]["id"] == participation_id
assert json_response(res, 200)["data"]["rejectParticipation"]["event"]["id"] ==
to_string(event.id)
assert json_response(res, 200)["data"]["rejectParticipation"]["actor"]["id"] ==
to_string(actor.id)
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] == "Participant not found"
end
test "reject_participation/3 with bad parameters", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted)
insert(:participant, event: event, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
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)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
rejectParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"Provided moderator actor ID doesn't have permission on this event"
end
end
end end