Add ability to add message for participation and improve participation

management interface

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-03-05 19:32:34 +01:00
parent 130a3cf23f
commit c732ec7f87
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
34 changed files with 736 additions and 368 deletions

View File

@ -16,8 +16,9 @@ A minimal file template [is available](https://framagit.org/framasoft/mobilizon/
Also make sure to remove the `EnvironmentFile=` line from the systemd service and set `Environment=MIX_ENV=prod` instead. See [the updated file](https://framagit.org/framasoft/mobilizon/blob/master/support/systemd/mobilizon.service). Also make sure to remove the `EnvironmentFile=` line from the systemd service and set `Environment=MIX_ENV=prod` instead. See [the updated file](https://framagit.org/framasoft/mobilizon/blob/master/support/systemd/mobilizon.service).
### Added ### Added
- Possibility to participate anonymously to an event - Possibility to participate to an event without an account
- Possibility to participate to a remote event (being redirected by providing federated identity) - Possibility to participate to a remote event (being redirected by providing federated identity)
- Possibility to add a note as a participant when event participation is manually validated (required when participating without an account)
- Possibility to change email address for the account - Possibility to change email address for the account
- Possibility to delete your account - Possibility to delete your account
@ -26,6 +27,7 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
- Signature validation also now checks if `Date` header has acceptable values - Signature validation also now checks if `Date` header has acceptable values
- Actor profiles are now stale after two days and have to be refetched - Actor profiles are now stale after two days and have to be refetched
- Actor keys are rotated some time after sending a `Delete` activity - Actor keys are rotated some time after sending a `Delete` activity
- Improved event participations managing interface
### Fixed ### Fixed
- Fixed URL search - Fixed URL search

View File

@ -21,7 +21,8 @@ Supported Activity | Supported Object
`Create` | `Note`, `Event` `Create` | `Note`, `Event`
`Delete` | `Object` `Delete` | `Object`
`Flag` | `Object` `Flag` | `Object`
`Follow` | `Object` `Follow` | `Object`
`Join` | `Event`
`Reject` | `Follow`, `Join` `Reject` | `Follow`, `Join`
`Remove` | `Note`, `Event` `Remove` | `Note`, `Event`
`Undo` | `Announce`, `Follow` `Undo` | `Announce`, `Follow`
@ -155,4 +156,28 @@ We add [an `address` property](https://schema.org/address), which we assume to b
}, },
"type": "Event" "type": "Event"
} }
```
### Join
#### participationMessage
We add a `participationMessage` property on a `Join` activity so that participants may transmit a note to event organizers, to motivate their participation when event participations are manually approved. This field is restricted to plain text.
```json
{
"type": "Join",
"object": "http://mobilizon.test/events/some-uuid",
"id": "http://mobilizon2.test/@admin/join/event/1",
"actor": "http://mobilizon2.test/@admin",
"participationMessage": "I want to join !",
"@context": [
{
"participationMessage": {
"@id": "mz:participationMessage",
"@type": "sc:Text"
}
}
]
}
``` ```

View File

@ -6,7 +6,6 @@
:loading="$apollo.queries.relayFollowers.loading" :loading="$apollo.queries.relayFollowers.loading"
ref="table" ref="table"
:checked-rows.sync="checkedRows" :checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed detailed
:show-detail-icon="false" :show-detail-icon="false"
paginated paginated

View File

@ -1,3 +1,4 @@
import {EventJoinOptions} from "@/types/event.model";
<docs> <docs>
A button to set your participation A button to set your participation
@ -80,7 +81,7 @@ A button to set your participation
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<span>{{ $t('as {identity}', {identity: currentActor.preferredUsername }) }}</span> <span>{{ $t('as {identity}', {identity: currentActor.name || `@${currentActor.preferredUsername}` }) }}</span>
</div> </div>
</div> </div>
</b-dropdown-item> </b-dropdown-item>
@ -96,14 +97,13 @@ A button to set your participation
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { EventModel, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; import { EventJoinOptions, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson, Person } from '@/types/actor'; import { IPerson, Person } from '@/types/actor';
import { CURRENT_ACTOR_CLIENT, IDENTITIES } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT, IDENTITIES } from '@/graphql/actor';
import { CURRENT_USER_CLIENT } from '@/graphql/user'; import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { CONFIG } from '@/graphql/config'; import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model'; import { IConfig } from '@/types/config.model';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import { FETCH_EVENT } from '@/graphql/event';
@Component({ @Component({
apollo: { apollo: {
@ -132,7 +132,11 @@ export default class ParticipationButton extends Vue {
RouteName = RouteName; RouteName = RouteName;
joinEvent(actor: IPerson) { joinEvent(actor: IPerson) {
this.$emit('joinEvent', actor); if (this.event.joinOptions === EventJoinOptions.RESTRICTED) {
this.$emit('joinEventWithConfirmation', actor);
} else {
this.$emit('joinEvent', actor);
}
} }
joinModal() { joinModal() {

View File

@ -0,0 +1,164 @@
<template>
<b-table
:data="data"
ref="queueTable"
detailed
detail-key="id"
:checked-rows.sync="checkedRows"
checkable
:is-row-checkable="row => row.role !== ParticipantRole.CREATOR"
checkbox-position="left"
default-sort="insertedAt"
default-sort-direction="asc"
:show-detail-icon="false"
:loading="this.$apollo.loading"
paginated
backend-pagination
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="total"
:per-page="perPage"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="page => $emit('page-change', page)"
@sort="(field, order) => $emit('sort', field, order)"
>
<template slot-scope="props">
<b-table-column field="insertedAt" :label="$t('Date')" sortable>
<b-tag type="is-success" class="has-text-centered">{{ props.row.insertedAt | formatDateString }}<br>{{ props.row.insertedAt | formatTimeString }}</b-tag>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" sortable v-if="showRole">
<span v-if="props.row.role === ParticipantRole.CREATOR">
{{ $t('Organizer') }}
</span>
<span v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
{{ $t('Participant') }}
</span>
</b-table-column>
<b-table-column field="actor.preferredUsername" :label="$t('Participant')" sortable>
<article class="media">
<figure class="media-left" v-if="props.row.actor.avatar">
<p class="image is-48x48">
<img :src="props.row.actor.avatar.url" alt="">
</p>
</figure>
<b-icon class="media-left" v-else-if="props.row.actor.preferredUsername === 'anonymous'" size="is-large" icon="incognito" />
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.preferredUsername !== 'anonymous'">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span><br />
<span class="is-size-7 has-text-grey">@{{ props.row.actor.preferredUsername }}</span>
</span>
<span v-else>
{{ $t('Anonymous participant') }}
</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="metadata.message" :label="$t('Message')">
<span @click="toggleQueueDetails(props.row)" :class="{ 'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH }" v-if="props.row.metadata && props.row.metadata.message">
{{ props.row.metadata.message | ellipsize }}
</span>
<span v-else class="has-text-grey">
{{ $t('No message') }}
</span>
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article v-html="nl2br(props.row.metadata.message)" />
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button @click="acceptParticipants(checkedRows)" type="is-success" v-if="canAcceptParticipants">
{{ $tc('No participant to approve|Approve participant|Approve {number} participants', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
<b-button @click="refuseParticipants(checkedRows)" type="is-danger" v-if="canRefuseParticipants">
{{ $tc('No participant to reject|Reject participant|Reject {number} participants', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
</div>
</template>
</b-table>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { Refs } from '@/shims-vue';
import { nl2br } from '@/utils/html';
import { asyncForEach } from '@/utils/asyncForEach';
const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({
filters: {
ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'),
},
})
export default class ParticipationTable extends Vue {
@Prop({ required: true, type: Array }) data!: IParticipant[];
@Prop({ required: true, type: Number }) total!: number;
@Prop({ required: true, type: Function }) acceptParticipant;
@Prop({ required: true, type: Function }) refuseParticipant;
@Prop({ required: false, type: Boolean, default: false }) showRole;
@Prop({ required: false, type: Number, default: 20 }) perPage;
checkedRows: IParticipant[] = [];
MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH;
nl2br = nl2br;
ParticipantRole = ParticipantRole;
$refs!: Refs<{
queueTable: any,
}>;
toggleQueueDetails(row: IParticipant) {
if (row.metadata.message && row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH) return;
this.$refs.queueTable.toggleDetails(row);
}
async acceptParticipants(participants: IParticipant[]) {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.acceptParticipant(participant);
});
this.checkedRows = [];
}
async refuseParticipants(participants: IParticipant[]) {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.refuseParticipant(participant);
});
this.checkedRows = [];
}
/**
* We can accept participants if at least one of them is not approved
*/
get canAcceptParticipants(): boolean {
return this.checkedRows.some(
(participant: IParticipant) => [ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role),
);
}
/**
* We can refuse participants if at least one of them is something different than not approved
*/
get canRefuseParticipants(): boolean {
return this.checkedRows.some((participant: IParticipant) => participant.role !== ParticipantRole.REJECTED);
}
}
</script>
<style lang="scss" scoped>
.ellipsed-message {
cursor: pointer;
}
.table {
span.tag {
height: initial;
}
}
</style>

View File

@ -29,7 +29,7 @@
<figure class="image is-32x32" v-if="identity.avatar"> <figure class="image is-32x32" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatar.url" alt="" /> <img class="is-rounded" :src="identity.avatar.url" alt="" />
</figure> </figure>
<b-icon v-else icon="account-circle" /> <b-icon v-else size="is-medium" icon="account-circle" />
</div> </div>
<div class="media-content"> <div class="media-content">
@ -180,6 +180,10 @@ nav {
background: $secondary; background: $secondary;
} }
span.icon.is-medium {
display: flex;
}
img { img {
max-height: 2.5em; max-height: 2.5em;
} }

View File

@ -7,18 +7,24 @@
<b-message type="is-info">{{ $t("Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.") }}</b-message> <b-message type="is-info">{{ $t("Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.") }}</b-message>
<b-message type="is-danger" v-if="error">{{ error }}</b-message> <b-message type="is-danger" v-if="error">{{ error }}</b-message>
<b-field :label="$t('Email')"> <b-field :label="$t('Email')">
<b-field> <b-input
<b-input type="email"
type="email" v-model="anonymousParticipation.email"
v-model="anonymousParticipation.email" placeholder="Your email"
placeholder="Your email" required>
required> </b-input>
</b-input>
<p class="control">
<b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button>
</p>
</b-field>
</b-field> </b-field>
<p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">{{ $t("The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.") }}</p>
<p v-else>{{ $t("If you want, you may send a message to the event organizer here.") }}</p>
<b-field :label="$t('Message')">
<b-input
type="textarea"
v-model="anonymousParticipation.message"
minlength="10"
:required="event.joinOptions === EventJoinOptions.RESTRICTED">
</b-input>
</b-field>
<b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button>
<div class="has-text-centered"> <div class="has-text-centered">
<b-button native-type="button" tag="a" type="is-text" @click="$router.go(-1)"> <b-button native-type="button" tag="a" type="is-text" @click="$router.go(-1)">
{{ $t('Back to previous page') }} {{ $t('Back to previous page') }}
@ -31,7 +37,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { EventModel, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; import { EventModel, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model';
import { FETCH_EVENT, JOIN_EVENT } from '@/graphql/event'; import { FETCH_EVENT, JOIN_EVENT } from '@/graphql/event';
import { IConfig } from '@/types/config.model'; import { IConfig } from '@/types/config.model';
import { CONFIG } from '@/graphql/config'; import { CONFIG } from '@/graphql/config';
@ -55,10 +61,11 @@ import { RouteName } from '@/router';
}) })
export default class ParticipationWithoutAccount extends Vue { export default class ParticipationWithoutAccount extends Vue {
@Prop({ type: String, required: true }) uuid!: string; @Prop({ type: String, required: true }) uuid!: string;
anonymousParticipation: { email: String } = { email: '' }; anonymousParticipation: { email: String, message: String } = { email: '', message: '' };
event!: IEvent; event!: IEvent;
config!: IConfig; config!: IConfig;
error: String|boolean = false; error: String|boolean = false;
EventJoinOptions = EventJoinOptions;
async joinEvent() { async joinEvent() {
this.error = false; this.error = false;
@ -69,6 +76,7 @@ export default class ParticipationWithoutAccount extends Vue {
eventId: this.event.id, eventId: this.event.id,
actorId: this.config.anonymous.actorId, actorId: this.config.anonymous.actorId,
email: this.anonymousParticipation.email, email: this.anonymousParticipation.email,
message: this.anonymousParticipation.message,
}, },
update: (store, { data }) => { update: (store, { data }) => {
if (data == null) return; if (data == null) return;

View File

@ -2,23 +2,28 @@ import gql from 'graphql-tag';
import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment'; import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment';
const participantQuery = ` const participantQuery = `
role, total,
id, elements {
actor { role,
preferredUsername, id,
avatar { actor {
url preferredUsername,
avatar {
url
},
name,
id,
domain
}, },
name, event {
id, id,
domain uuid
}, },
event { metadata {
id, cancellationToken,
uuid message
}, },
metadata { insertedAt
cancellationToken
} }
`; `;
@ -371,11 +376,12 @@ export const EDIT_EVENT = gql`
`; `;
export const JOIN_EVENT = gql` export const JOIN_EVENT = gql`
mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String) { mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String, $message: String) {
joinEvent( joinEvent(
eventId: $eventId, eventId: $eventId,
actorId: $actorId, actorId: $actorId,
email: $email email: $email,
message: $message
) { ) {
${participantQuery} ${participantQuery}
} }

View File

@ -23,7 +23,6 @@
"Allow all comments": "Allow all comments", "Allow all comments": "Allow all comments",
"Allow registrations": "Allow registrations", "Allow registrations": "Allow registrations",
"An error has occurred.": "An error has occurred.", "An error has occurred.": "An error has occurred.",
"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "And no anonymous participations|And one anonymous participation|And {count} anonymous participations",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Anonymous participants will be asked to confirm their participation through e-mail.", "Anonymous participants will be asked to confirm their participation through e-mail.": "Anonymous participants will be asked to confirm their participation through e-mail.",
"Anonymous participations": "Anonymous participations", "Anonymous participations": "Anonymous participations",
"Approve": "Approve", "Approve": "Approve",
@ -467,5 +466,17 @@
"{count} requests waiting": "{count} requests waiting", "{count} requests waiting": "{count} requests waiting",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.", "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks", "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors" "© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors",
"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.",
"If you want, you may send a message to the event organizer here.": "If you want, you may send a message to the event organizer here.",
"Message": "Message",
"Anonymous participant": "Anonymous participant",
"No message": "No message",
"No participant to approve|Approve participant|Approve {number} participants": "No participant to approve|Approve participant|Approve {number} participants",
"No participant to reject|Reject participant|Reject {number} participants": "No participant to reject|Reject participant|Reject {number} participants",
"Role": "Role",
"Participant": "Participant",
"Participation confirmation": "Participation confirmation",
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?",
"Confirm my participation": "Confirm my participation"
} }

View File

@ -23,7 +23,6 @@
"Allow all comments": "Permitir todos los comentarios", "Allow all comments": "Permitir todos los comentarios",
"Allow registrations": "Permitir registros", "Allow registrations": "Permitir registros",
"An error has occurred.": "Se ha producido un error.", "An error has occurred.": "Se ha producido un error.",
"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Y sin participaciones anónimas|Y una participación anónima|Y {count} participaciones anónimas",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Los participantes anónimos deberán confirmar su participación por correo electrónico.", "Anonymous participants will be asked to confirm their participation through e-mail.": "Los participantes anónimos deberán confirmar su participación por correo electrónico.",
"Anonymous participations": "Participaciones anónimas", "Anonymous participations": "Participaciones anónimas",
"Approve": "Aprobar", "Approve": "Aprobar",

View File

@ -22,7 +22,6 @@
"Allow all comments": "Salli kaikki kommentit", "Allow all comments": "Salli kaikki kommentit",
"Allow registrations": "Salli rekisteröityminen", "Allow registrations": "Salli rekisteröityminen",
"An error has occurred.": "Tapahtui virhe.", "An error has occurred.": "Tapahtui virhe.",
"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Ei anonyymejä osallistujia|Myös yksi anonyymi osallistuja|Myös {count} anonyymiä osallistujaa",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Anonyymejä osallistujia pyydetään vahvistamaan osallistumisensa sähköpostitse.", "Anonymous participants will be asked to confirm their participation through e-mail.": "Anonyymejä osallistujia pyydetään vahvistamaan osallistumisensa sähköpostitse.",
"Anonymous participations": "Anonyymit osallistujat", "Anonymous participations": "Anonyymit osallistujat",
"Approve": "Hyväksy", "Approve": "Hyväksy",

View File

@ -23,7 +23,6 @@
"Allow all comments": "Autoriser tous les commentaires", "Allow all comments": "Autoriser tous les commentaires",
"Allow registrations": "Autoriser les inscriptions", "Allow registrations": "Autoriser les inscriptions",
"An error has occurred.": "Une erreur est survenue.", "An error has occurred.": "Une erreur est survenue.",
"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Et aucune participation anonyme|Et une participation anonyme|Et {count} participations anonymes",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par email.", "Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par email.",
"Anonymous participations": "Participations anonymes", "Anonymous participations": "Participations anonymes",
"Approve": "Approuver", "Approve": "Approuver",
@ -252,14 +251,14 @@
"Or": "Ou", "Or": "Ou",
"Organized": "Organisés", "Organized": "Organisés",
"Organized by {name}": "Organisé par {name}", "Organized by {name}": "Organisé par {name}",
"Organizer": "Organisateur", "Organizer": "Organisateur⋅ice",
"Other software may also support this.": "D'autres logiciels peuvent également supporter cette fonctionnalité.", "Other software may also support this.": "D'autres logiciels peuvent également supporter cette fonctionnalité.",
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.", "Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.",
"Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)", "Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
"Page not found": "Page non trouvée", "Page not found": "Page non trouvée",
"Participant already was rejected.": "Le participant a déjà été refusé.", "Participant already was rejected.": "Le participant a déjà été refusé.",
"Participant has already been approved as participant.": "Le participant a déjà été approuvé en tant que participant.", "Participant has already been approved as participant.": "Le participant a déjà été approuvé en tant que participant.",
"Participants": "Participants", "Participants": "Participant⋅e⋅s",
"Participate": "Participer", "Participate": "Participer",
"Participate using your email address": "Participer en utilisant votre adresse email", "Participate using your email address": "Participer en utilisant votre adresse email",
"Participation approval": "Validation des participations", "Participation approval": "Validation des participations",
@ -473,5 +472,17 @@
"{count} requests waiting": "Une demande en attente|{count} demandes en attente", "{count} requests waiting": "Une demande en attente|{count} demandes en attente",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.", "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines", "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "L'organisateur⋅ice de l'événement valide les participations manuellement. Comme vous avez choisi de participer sans compte, merci d'expliquer pourquoi vous voulez participer à cet événement.",
"If you want, you may send a message to the event organizer here.": "Si vous le désirez, vous pouvez laisser un message pour l'organisateur⋅ice de l'événement ci-dessous.",
"Message": "Message",
"Anonymous participant": "Participant⋅e anonyme",
"No message": "Pas de message",
"No participant to approve|Approve participant|Approve {number} participants": "Aucun⋅e participant⋅e à valider|Valider le ou la participant⋅e|Valider {number} participant⋅es",
"No participant to reject|Reject participant|Reject {number} participants": "Aucun⋅e participant⋅e à refuser|Refuser le ou la participant⋅e|Refuser {number} participant⋅es",
"Role": "Rôle",
"Participant": "Participant⋅e",
"Participation confirmation": "Confirmation de votre participation",
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "L'organisateur⋅ice de l'événement a choisi de valider manuellement les demandes de participation. Voulez-vous ajouter un petit message pour expliquer pourquoi vous souhaitez participer à cet événement ?",
"Confirm my participation": "Confirmer ma participation"
} }

View File

@ -25,7 +25,6 @@
"Allow all comments": "Autorizar totes los comentaris", "Allow all comments": "Autorizar totes los comentaris",
"Allow registrations": "Permetre las inscripcions", "Allow registrations": "Permetre las inscripcions",
"An error has occurred.": "Una error ses producha.", "An error has occurred.": "Una error ses producha.",
"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "E cap de participacion anonima|E una participacion anonima|E {count} participacions anonima",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Òm demandarà als participants anonims de confirmar lor venguda via un corrièl.", "Anonymous participants will be asked to confirm their participation through e-mail.": "Òm demandarà als participants anonims de confirmar lor venguda via un corrièl.",
"Anonymous participations": "Participacions anonimas", "Anonymous participations": "Participacions anonimas",
"Approve": "Aprovar", "Approve": "Aprovar",

View File

@ -22,7 +22,6 @@
"Allow all comments": "Permitir todos comentários", "Allow all comments": "Permitir todos comentários",
"Allow registrations": "Permitir inscrições", "Allow registrations": "Permitir inscrições",
"An error has occurred.": "Ocorreu um erro.", "An error has occurred.": "Ocorreu um erro.",
"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "E nenhuma participação anônima|E uma participação anônima|E {count} participações anônimas",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Os participantes anônimos deverão confirmar sua participação por email.", "Anonymous participants will be asked to confirm their participation through e-mail.": "Os participantes anônimos deverão confirmar sua participação por email.",
"Anonymous participations": "Participações anônimas", "Anonymous participations": "Participações anônimas",
"Approve": "Aprovar", "Approve": "Aprovar",

View File

@ -1,5 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import { ColorModifiers } from 'buefy/types/helpers'; import { ColorModifiers } from 'buefy/types/helpers';
import { Route, RawLocation } from 'vue-router';
declare module 'vue/types/vue' { declare module 'vue/types/vue' {
interface Vue { interface Vue {
@ -8,6 +9,23 @@ declare module 'vue/types/vue' {
error: (message: string) => void; error: (message: string) => void;
info: (message: string) => void; info: (message: string) => void;
}; };
beforeRouteEnter?(
to: Route,
from: Route,
next: (to?: RawLocation | false | ((vm: Vue) => void)) => void,
): void;
beforeRouteLeave?(
to: Route,
from: Route,
next: (to?: RawLocation | false | ((vm: Vue) => void)) => void,
): void;
beforeRouteUpdate?(
to: Route,
from: Route,
next: (to?: RawLocation | false | ((vm: Vue) => void)) => void,
): void;
} }
} }

View File

@ -63,7 +63,7 @@ export const eventRoutes: RouteConfig[] = [
props: { isUpdate: true }, props: { isUpdate: true },
}, },
{ {
path: '/events/participations/:eventId', path: '/events/:eventId/participations',
name: EventRouteName.PARTICIPATIONS, name: EventRouteName.PARTICIPATIONS,
component: participations, component: participations,
meta: { requiredAuth: true }, meta: { requiredAuth: true },

View File

@ -3,6 +3,7 @@ import { Address, IAddress } from '@/types/address.model';
import { ITag } from '@/types/tag.model'; import { ITag } from '@/types/tag.model';
import { IPicture } from '@/types/picture.model'; import { IPicture } from '@/types/picture.model';
import { IComment } from '@/types/comment.model'; import { IComment } from '@/types/comment.model';
import { Paginate } from '@/types/paginate';
export enum EventStatus { export enum EventStatus {
TENTATIVE = 'TENTATIVE', TENTATIVE = 'TENTATIVE',
@ -59,7 +60,8 @@ export interface IParticipant {
role: ParticipantRole; role: ParticipantRole;
actor: IActor; actor: IActor;
event: IEvent; event: IEvent;
metadata: { cancellationToken?: string }; metadata: { cancellationToken?: string, message?: string };
insertedAt?: Date;
} }
export class Participant implements IParticipant { export class Participant implements IParticipant {
@ -68,6 +70,7 @@ export class Participant implements IParticipant {
actor!: IActor; actor!: IActor;
role: ParticipantRole = ParticipantRole.NOT_APPROVED; role: ParticipantRole = ParticipantRole.NOT_APPROVED;
metadata = {}; metadata = {};
insertedAt?: Date;
constructor(hash?: IParticipant) { constructor(hash?: IParticipant) {
if (!hash) return; if (!hash) return;
@ -77,6 +80,7 @@ export class Participant implements IParticipant {
this.actor = new Actor(hash.actor); this.actor = new Actor(hash.actor);
this.role = hash.role; this.role = hash.role;
this.metadata = hash.metadata; this.metadata = hash.metadata;
this.insertedAt = hash.insertedAt;
} }
} }
@ -132,7 +136,7 @@ export interface IEvent {
organizerActor?: IActor; organizerActor?: IActor;
attributedTo: IActor; attributedTo: IActor;
participantStats: IEventParticipantStats; participantStats: IEventParticipantStats;
participants: IParticipant[]; participants: Paginate<IParticipant>;
relatedEvents: IEvent[]; relatedEvents: IEvent[];
comments: IComment[]; comments: IComment[];
@ -205,7 +209,7 @@ export class EventModel implements IEvent {
publishAt = new Date(); publishAt = new Date();
participantStats = { notApproved: 0, notConfirmed: 0, rejected: 0, participant: 0, moderator: 0, administrator: 0, creator: 0, going: 0 }; participantStats = { notApproved: 0, notConfirmed: 0, rejected: 0, participant: 0, moderator: 0, administrator: 0, creator: 0, going: 0 };
participants: IParticipant[] = []; participants!: Paginate<IParticipant>;
relatedEvents: IEvent[] = []; relatedEvents: IEvent[] = [];
comments: IComment[] = []; comments: IComment[] = [];

View File

@ -0,0 +1,7 @@
async function asyncForEach(array, callback) {
for (let index = 0; index < array.length; index += 1) {
await callback(array[index], index, array);
}
}
export { asyncForEach };

View File

@ -45,6 +45,7 @@
:current-actor="currentActor" :current-actor="currentActor"
@joinEvent="joinEvent" @joinEvent="joinEvent"
@joinModal="isJoinModalActive = true" @joinModal="isJoinModalActive = true"
@joinEventWithConfirmation="joinEventWithConfirmation"
@confirmLeave="confirmLeave" @confirmLeave="confirmLeave"
/> />
<b-button type="is-text" v-if="anonymousParticipation !== null" @click="cancelAnonymousParticipation">{{ $t('Cancel anonymous participation')}}</b-button> <b-button type="is-text" v-if="anonymousParticipation !== null" @click="cancelAnonymousParticipation">{{ $t('Cancel anonymous participation')}}</b-button>
@ -263,13 +264,44 @@
<button <button
class="button is-primary" class="button is-primary"
ref="confirmButton" ref="confirmButton"
@click="joinEvent(identity)"> @click="event.joinOptions === EventJoinOptions.RESTRICTED ? joinEventWithConfirmation(identity) : joinEvent(identity)">
{{ $t('Confirm my particpation') }} {{ $t('Confirm my particpation') }}
</button> </button>
</footer> </footer>
</template> </template>
</identity-picker> </identity-picker>
</b-modal> </b-modal>
<b-modal :active.sync="isJoinConfirmationModalActive" has-modal-card ref="joinConfirmationModal">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Participation confirmation')}}</p>
</header>
<section class="modal-card-body">
<p>{{ $t('The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?') }}</p>
<form @submit.prevent="joinEvent(actorForConfirmation, messageForConfirmation)">
<b-field :label="$t('Message')">
<b-input
type="textarea"
size="is-medium"
v-model="messageForConfirmation"
minlength="10">
</b-input>
</b-field>
<div class="buttons">
<b-button
native-type="button"
class="button"
ref="cancelButton"
@click="isJoinConfirmationModalActive = false">
{{ $t('Cancel') }}
</b-button>
<b-button type="is-primary" native-type="submit">{{ $t('Confirm my participation') }}</b-button>
</div>
</form>
</section>
</div>
</b-modal>
</div> </div>
</transition> </transition>
</div> </div>
@ -281,11 +313,10 @@ import {
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED, EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT, FETCH_EVENT,
JOIN_EVENT, JOIN_EVENT,
LEAVE_EVENT,
} from '@/graphql/event'; } from '@/graphql/event';
import { Component, Prop, Watch } from 'vue-property-decorator'; import { Component, Prop, Watch } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model'; import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model';
import { IPerson, Person } from '@/types/actor'; import { IPerson, Person } from '@/types/actor';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint'; import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue'; import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
@ -398,12 +429,16 @@ export default class Event extends EventMixin {
showMap: boolean = false; showMap: boolean = false;
isReportModalActive: boolean = false; isReportModalActive: boolean = false;
isJoinModalActive: boolean = false; isJoinModalActive: boolean = false;
isJoinConfirmationModalActive: boolean = false;
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
EventStatus = EventStatus; EventStatus = EventStatus;
EventJoinOptions = EventJoinOptions;
RouteName = RouteName; RouteName = RouteName;
observer!: IntersectionObserver; observer!: IntersectionObserver;
loadComments: boolean = false; loadComments: boolean = false;
anonymousParticipation: boolean|null = null; anonymousParticipation: boolean|null = null;
actorForConfirmation!: IPerson;
messageForConfirmation: string = '';
get eventTitle() { get eventTitle() {
if (!this.event) return undefined; if (!this.event) return undefined;
@ -506,7 +541,13 @@ export default class Event extends EventMixin {
} }
} }
async joinEvent(identity: IPerson) { joinEventWithConfirmation(actor: IPerson) {
this.isJoinConfirmationModalActive = true;
this.actorForConfirmation = actor;
}
async joinEvent(identity: IPerson, message: string|null = null) {
this.isJoinConfirmationModalActive = false;
this.isJoinModalActive = false; this.isJoinModalActive = false;
try { try {
const { data } = await this.$apollo.mutate<{ joinEvent: IParticipant }>({ const { data } = await this.$apollo.mutate<{ joinEvent: IParticipant }>({
@ -514,6 +555,7 @@ export default class Event extends EventMixin {
variables: { variables: {
eventId: this.event.id, eventId: this.event.id,
actorId: identity.id, actorId: identity.id,
message,
}, },
update: (store, { data }) => { update: (store, { data }) => {
if (data == null) return; if (data == null) return;

View File

@ -1,3 +1,4 @@
import {ParticipantRole} from "@/types/event.model";
<template> <template>
<main class="container"> <main class="container">
<b-tabs type="is-boxed" v-if="event" v-model="activeTab"> <b-tabs type="is-boxed" v-if="event" v-model="activeTab">
@ -7,22 +8,17 @@
<span>{{ $t('Participants')}} <b-tag rounded> {{ participantStats.going }} </b-tag> </span> <span>{{ $t('Participants')}} <b-tag rounded> {{ participantStats.going }} </b-tag> </span>
</template> </template>
<template> <template>
<section v-if="participantsAndCreators.length > 0"> <section v-if="participants && participants.total > 0">
<h2 class="title">{{ $t('Participants') }}</h2> <h2 class="title">{{ $t('Participants') }}</h2>
<p v-if="confirmedAnonymousParticipantsCountCount > 1"> <ParticipationTable
{{ $tc('And no anonymous participations|And one anonymous participation|And {count} anonymous participations', confirmedAnonymousParticipantsCountCount, { count: confirmedAnonymousParticipantsCountCount}) }} :data="participants.elements"
</p> :accept-participant="acceptParticipant"
<div class="columns is-multiline"> :refuse-participant="refuseParticipant"
<div class="column is-one-quarter-desktop" v-for="participant in participantsAndCreators" :key="participant.actor.id"> :showRole="true"
<participant-card :total="participants.total"
v-if="participant.actor.id !== config.anonymous.actorId" :perPage="PARTICIPANTS_PER_PAGE"
:participant="participant" @page-change="(page) => participantPage = page"
:accept="acceptParticipant" />
:reject="refuseParticipant"
:exclude="refuseParticipant"
/>
</div>
</div>
</section> </section>
</template> </template>
</b-tab-item> </b-tab-item>
@ -32,18 +28,16 @@
<span>{{ $t('Requests') }} <b-tag rounded> {{ participantStats.notApproved }} </b-tag> </span> <span>{{ $t('Requests') }} <b-tag rounded> {{ participantStats.notApproved }} </b-tag> </span>
</template> </template>
<template> <template>
<section v-if="queue.length > 0"> <section v-if="queue && queue.total > 0">
<h2 class="title">{{ $t('Waiting list') }}</h2> <h2 class="title">{{ $t('Waiting list') }}</h2>
<div class="columns"> <ParticipationTable
<div class="column is-one-quarter-desktop" v-for="participant in queue" :key="participant.actor.id"> :data="queue.elements"
<participant-card :accept-participant="acceptParticipant"
:participant="participant" :refuse-participant="refuseParticipant"
:accept="acceptParticipant" :total="queue.total"
:reject="refuseParticipant" :perPage="PARTICIPANTS_PER_PAGE"
:exclude="refuseParticipant" @page-change="(page) => queuePage = page"
/> />
</div>
</div>
</section> </section>
</template> </template>
</b-tab-item> </b-tab-item>
@ -53,18 +47,16 @@
<span>{{ $t('Rejected')}} <b-tag rounded> {{ participantStats.rejected }} </b-tag> </span> <span>{{ $t('Rejected')}} <b-tag rounded> {{ participantStats.rejected }} </b-tag> </span>
</template> </template>
<template> <template>
<section v-if="rejected.length > 0"> <section v-if="rejected && rejected.total > 0">
<h2 class="title">{{ $t('Rejected participations') }}</h2> <h2 class="title">{{ $t('Rejected participations') }}</h2>
<div class="columns"> <ParticipationTable
<div class="column is-one-quarter-desktop" v-for="participant in rejected" :key="participant.actor.id"> :data="rejected.elements"
<participant-card :accept-participant="acceptParticipant"
:participant="participant" :refuse-participant="refuseParticipant"
:accept="acceptParticipant" :total="rejected.total"
:reject="refuseParticipant" :perPage="PARTICIPANTS_PER_PAGE"
:exclude="refuseParticipant" @page-change="(page) => rejectedPage = page"
/> />
</div>
</div>
</section> </section>
</template> </template>
</b-tab-item> </b-tab-item>
@ -81,9 +73,15 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
import { CONFIG } from '@/graphql/config'; import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model'; import { IConfig } from '@/types/config.model';
import ParticipationTable from '@/components/Event/ParticipationTable.vue';
import { Paginate } from '@/types/paginate';
const PARTICIPANTS_PER_PAGE = 20;
const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({ @Component({
components: { components: {
ParticipationTable,
ParticipantCard, ParticipantCard,
}, },
apollo: { apollo: {
@ -97,7 +95,7 @@ import { IConfig } from '@/types/config.model';
return { return {
uuid: this.eventId, uuid: this.eventId,
page: 1, page: 1,
limit: 10, limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.PARTICIPANT].join(), roles: [ParticipantRole.PARTICIPANT].join(),
actorId: this.currentActor.id, actorId: this.currentActor.id,
}; };
@ -106,18 +104,18 @@ import { IConfig } from '@/types/config.model';
return !this.currentActor.id; return !this.currentActor.id;
}, },
}, },
organizers: { participants: {
query: PARTICIPANTS, query: PARTICIPANTS,
variables() { variables() {
return { return {
uuid: this.eventId, uuid: this.eventId,
page: 1, page: this.participantPage,
limit: 20, limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.CREATOR].join(), roles: [ParticipantRole.CREATOR, ParticipantRole.PARTICIPANT].join(),
actorId: this.currentActor.id, actorId: this.currentActor.id,
}; };
}, },
update: data => data.event.participants.map(participation => new Participant(participation)), update(data) { return this.dataTransform(data); },
skip() { skip() {
return !this.currentActor.id; return !this.currentActor.id;
}, },
@ -127,13 +125,13 @@ import { IConfig } from '@/types/config.model';
variables() { variables() {
return { return {
uuid: this.eventId, uuid: this.eventId,
page: 1, page: this.queuePage,
limit: 20, limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.NOT_APPROVED].join(), roles: [ParticipantRole.NOT_APPROVED].join(),
actorId: this.currentActor.id, actorId: this.currentActor.id,
}; };
}, },
update: data => data.event.participants.map(participation => new Participant(participation)), update(data) { return this.dataTransform(data); },
skip() { skip() {
return !this.currentActor.id; return !this.currentActor.id;
}, },
@ -143,27 +141,36 @@ import { IConfig } from '@/types/config.model';
variables() { variables() {
return { return {
uuid: this.eventId, uuid: this.eventId,
page: 1, page: this.rejectedPage,
limit: 20, limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.REJECTED].join(), roles: [ParticipantRole.REJECTED].join(),
actorId: this.currentActor.id, actorId: this.currentActor.id,
}; };
}, },
update: data => data.event.participants.map(participation => new Participant(participation)), update(data) { return this.dataTransform(data); },
skip() { skip() {
return !this.currentActor.id; return !this.currentActor.id;
}, },
}, },
}, },
filters: {
ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'),
},
}) })
export default class Participants extends Vue { export default class Participants extends Vue {
@Prop({ required: true }) eventId!: string; @Prop({ required: true }) eventId!: string;
page: number = 1; page: number = 1;
limit: number = 10; limit: number = 10;
organizers: IParticipant[] = []; participants!: Paginate<IParticipant>;
queue: IParticipant[] = []; participantPage: number = 1;
rejected: IParticipant[] = [];
queue!: Paginate<IParticipant>;
queuePage: number = 1;
rejected!: Paginate<IParticipant>;
rejectedPage: number = 1;
event!: IEvent; event!: IEvent;
config!: IConfig; config!: IConfig;
@ -173,23 +180,20 @@ export default class Participants extends Vue {
hasMoreParticipants: boolean = false; hasMoreParticipants: boolean = false;
activeTab: number = 0; activeTab: number = 0;
PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE;
dataTransform(data): Paginate<Participant> {
return {
total: data.event.participants.total,
elements: data.event.participants.elements.map(participation => new Participant(participation)),
};
}
get participantStats(): IEventParticipantStats | null { get participantStats(): IEventParticipantStats | null {
if (!this.event) return null; if (!this.event) return null;
return this.event.participantStats; return this.event.participantStats;
} }
get participantsAndCreators(): IParticipant[] {
if (this.event) {
return [...this.organizers, ...this.event.participants]
.filter(participant => [ParticipantRole.PARTICIPANT, ParticipantRole.CREATOR].includes(participant.role));
}
return [];
}
get confirmedAnonymousParticipantsCountCount(): number {
return this.participantsAndCreators.filter(({ actor: { id } }) => id === this.config.anonymous.actorId).length;
}
@Watch('participantStats', { deep: true }) @Watch('participantStats', { deep: true })
watchParticipantStats(stats: IEventParticipantStats) { watchParticipantStats(stats: IEventParticipantStats) {
if (!stats) return; if (!stats) return;
@ -232,8 +236,8 @@ export default class Participants extends Vue {
}, },
}); });
if (data) { if (data) {
this.queue = this.queue.filter(participant => participant.id !== data.updateParticipation.id); this.queue.elements = this.queue.elements.filter(participant => participant.id !== data.updateParticipation.id);
this.rejected = this.rejected.filter(participant => participant.id !== data.updateParticipation.id); this.rejected.elements = this.rejected.elements.filter(participant => participant.id !== data.updateParticipation.id);
this.event.participantStats.going += 1; this.event.participantStats.going += 1;
if (participant.role === ParticipantRole.NOT_APPROVED) { if (participant.role === ParticipantRole.NOT_APPROVED) {
this.event.participantStats.notApproved -= 1; this.event.participantStats.notApproved -= 1;
@ -242,7 +246,7 @@ export default class Participants extends Vue {
this.event.participantStats.rejected -= 1; this.event.participantStats.rejected -= 1;
} }
participant.role = ParticipantRole.PARTICIPANT; participant.role = ParticipantRole.PARTICIPANT;
this.event.participants.push(participant); this.event.participants.elements.push(participant);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -260,8 +264,10 @@ export default class Participants extends Vue {
}, },
}); });
if (data) { if (data) {
this.event.participants = this.event.participants.filter(participant => participant.id !== data.updateParticipation.id); this.event.participants.elements = this.event.participants.elements.filter(
this.queue = this.queue.filter(participant => participant.id !== data.updateParticipation.id); participant => participant.id !== data.updateParticipation.id,
);
this.queue.elements = this.queue.elements.filter(participant => participant.id !== data.updateParticipation.id);
this.event.participantStats.rejected += 1; this.event.participantStats.rejected += 1;
if (participant.role === ParticipantRole.PARTICIPANT) { if (participant.role === ParticipantRole.PARTICIPANT) {
this.event.participantStats.participant -= 1; this.event.participantStats.participant -= 1;
@ -271,8 +277,8 @@ export default class Participants extends Vue {
this.event.participantStats.notApproved -= 1; this.event.participantStats.notApproved -= 1;
} }
participant.role = ParticipantRole.REJECTED; participant.role = ParticipantRole.REJECTED;
this.rejected = this.rejected.filter(participantIn => participantIn.id !== participant.id); this.rejected.elements = this.rejected.elements.filter(participantIn => participantIn.id !== participant.id);
this.rejected.push(participant); this.rejected.elements.push(participant);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -439,7 +439,10 @@ defmodule Mobilizon.Federation.ActivityPub do
event_id: event.id, event_id: event.id,
actor_id: actor.id, actor_id: actor.id,
url: Map.get(additional, :url), url: Map.get(additional, :url),
metadata: Map.get(additional, :metadata) metadata:
additional
|> Map.get(:metadata, %{})
|> Map.update(:message, nil, &String.trim(HtmlSanitizeEx.strip_tags(&1)))
}), }),
join_data <- Convertible.model_to_as(participant), join_data <- Convertible.model_to_as(participant),
audience <- audience <-

View File

@ -306,13 +306,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
def handle_incoming( def handle_incoming(
%{"type" => "Join", "object" => object, "actor" => _actor, "id" => id} = data %{
"type" => "Join",
"object" => object,
"actor" => _actor,
"id" => id,
"participationMessage" => note
} = data
) do ) do
with actor <- Utils.get_actor(data), with actor <- Utils.get_actor(data),
{:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor), {:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor),
object <- Utils.get_url(object), object <- Utils.get_url(object),
{:ok, object} <- ActivityPub.fetch_object_from_url(object), {:ok, object} <- ActivityPub.fetch_object_from_url(object),
{:ok, activity, object} <- ActivityPub.join(object, actor, false, %{url: id}) do {:ok, activity, object} <-
ActivityPub.join(object, actor, false, %{url: id, metadata: %{message: note}}) do
{:ok, activity, object} {:ok, activity, object}
else else
e -> e ->

View File

@ -77,6 +77,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"anonymousParticipationEnabled" => %{ "anonymousParticipationEnabled" => %{
"@id" => "mz:anonymousParticipationEnabled", "@id" => "mz:anonymousParticipationEnabled",
"@type" => "sc:Boolean" "@type" => "sc:Boolean"
},
"participationMessage" => %{
"@id" => "mz:participationMessage",
"@type" => "sc:Text"
} }
} }
] ]

View File

@ -25,7 +25,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Participant do
"type" => "Join", "type" => "Join",
"id" => participant.url, "id" => participant.url,
"actor" => participant.actor.url, "actor" => participant.actor.url,
"object" => participant.event.url "object" => participant.event.url,
"participationMessage" => Map.get(participant.metadata, :message)
} }
end end
end end

View File

@ -102,7 +102,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
end end
def list_participants_for_event(_, _args, _resolution) do def list_participants_for_event(_, _args, _resolution) do
{:ok, []} {:ok, %{total: 0, elements: []}}
end end
def stats_participants_going(%EventParticipantStats{} = stats, _args, _resolution) do def stats_participants_going(%EventParticipantStats{} = stats, _args, _resolution) do

View File

@ -17,12 +17,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
""" """
def actor_join_event( def actor_join_event(
_parent, _parent,
%{actor_id: actor_id, event_id: event_id}, %{actor_id: actor_id, event_id: event_id} = args,
%{context: %{current_user: %User{} = user}} %{context: %{current_user: %User{} = user}}
) do ) do
case User.owns_actor(user, actor_id) do case User.owns_actor(user, actor_id) do
{:is_owned, %Actor{} = actor} -> {:is_owned, %Actor{} = actor} ->
do_actor_join_event(actor, event_id) do_actor_join_event(actor, event_id, args)
_ -> _ ->
{:error, "Actor id is not owned by authenticated user"} {:error, "Actor id is not owned by authenticated user"}
@ -136,7 +136,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
_parent, _parent,
%{actor_id: actor_id, event_id: event_id, token: token}, %{actor_id: actor_id, event_id: event_id, token: token},
_resolution _resolution
) do )
when not is_nil(token) do
with {:anonymous_participation_enabled, true} <- with {:anonymous_participation_enabled, true} <-
{:anonymous_participation_enabled, Config.anonymous_participation?()}, {:anonymous_participation_enabled, Config.anonymous_participation?()},
{:anonymous_actor_id, true} <- {:anonymous_actor_id, true} <-

View File

@ -65,7 +65,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:participant_stats, :participant_stats) field(:participant_stats, :participant_stats)
field(:participants, list_of(:participant), description: "The event's participants") do field(:participants, :paginated_participant_list, description: "The event's participants") do
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
arg(:roles, :string, default_value: "") arg(:roles, :string, default_value: "")

View File

@ -33,12 +33,21 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
field(:metadata, :participant_metadata, field(:metadata, :participant_metadata,
description: "The metadata associated to this participant" description: "The metadata associated to this participant"
) )
field(:inserted_at, :datetime, description: "The datetime this participant was created")
end end
object :participant_metadata do object :participant_metadata do
field(:cancellation_token, :string, field(:cancellation_token, :string,
description: "The eventual token to leave an event when user is anonymous" description: "The eventual token to leave an event when user is anonymous"
) )
field(:message, :string, description: "The eventual message the participant left")
end
object :paginated_participant_list do
field(:elements, list_of(:participant), description: "A list of participants")
field(:total, :integer, description: "The total number of participants in the list")
end end
enum :participant_role_enum do enum :participant_role_enum do
@ -64,6 +73,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
arg(:event_id, non_null(:id)) arg(:event_id, non_null(:id))
arg(:actor_id, non_null(:id)) arg(:actor_id, non_null(:id))
arg(:email, :string) arg(:email, :string)
arg(:message, :string)
resolve(&Participant.actor_join_event/3) resolve(&Participant.actor_join_event/3)
end end

View File

@ -759,7 +759,7 @@ defmodule Mobilizon.Events do
Default behaviour is to not return :not_approved or :not_confirmed participants Default behaviour is to not return :not_approved or :not_confirmed participants
""" """
@spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) :: @spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
[Participant.t()] Page.t()
def list_participants_for_event( def list_participants_for_event(
id, id,
roles \\ @default_participant_roles, roles \\ @default_participant_roles,
@ -769,8 +769,7 @@ defmodule Mobilizon.Events do
id id
|> list_participants_for_event_query() |> list_participants_for_event_query()
|> filter_role(roles) |> filter_role(roles)
|> Page.paginate(page, limit) |> Page.build_page(page, limit)
|> Repo.all()
end end
@spec list_actors_participants_for_event(String.t()) :: [Actor.t()] @spec list_actors_participants_for_event(String.t()) :: [Actor.t()]

View File

@ -18,11 +18,15 @@ defmodule Mobilizon.Events.Participant do
role: ParticipantRole.t(), role: ParticipantRole.t(),
url: String.t(), url: String.t(),
event: Event.t(), event: Event.t(),
actor: Actor.t() actor: Actor.t(),
metadata: Map.t()
} }
@required_attrs [:url, :role, :event_id, :actor_id] @required_attrs [:url, :role, :event_id, :actor_id]
@attrs @required_attrs @attrs @required_attrs
@metadata_attrs [:email, :confirmation_token, :cancellation_token, :message]
@timestamps_opts [type: :utc_datetime]
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
schema "participants" do schema "participants" do
@ -33,6 +37,7 @@ defmodule Mobilizon.Events.Participant do
field(:email, :string) field(:email, :string)
field(:confirmation_token, :string) field(:confirmation_token, :string)
field(:cancellation_token, :string) field(:cancellation_token, :string)
field(:message, :string)
end end
belongs_to(:event, Event, primary_key: true) belongs_to(:event, Event, primary_key: true)
@ -70,7 +75,7 @@ defmodule Mobilizon.Events.Participant do
defp metadata_changeset(schema, params) do defp metadata_changeset(schema, params) do
schema schema
|> cast(params, [:email, :confirmation_token, :cancellation_token]) |> cast(params, @metadata_attrs)
|> Checker.validate_changeset() |> Checker.validate_changeset()
end end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api # source: http://localhost:4000/api
# timestamp: Thu Feb 13 2020 11:32:20 GMT+0100 (GMT+01:00) # timestamp: Wed Mar 04 2020 10:26:53 GMT+0100 (GMT+01:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -839,6 +839,9 @@ type Participant {
"""The participation ID""" """The participation ID"""
id: ID id: ID
"""The datetime this participant was created"""
insertedAt: DateTime
"""The metadata associated to this participant""" """The metadata associated to this participant"""
metadata: ParticipantMetadata metadata: ParticipantMetadata
@ -849,6 +852,9 @@ type Participant {
type ParticipantMetadata { type ParticipantMetadata {
"""The eventual token to leave an event when user is anonymous""" """The eventual token to leave an event when user is anonymous"""
cancellationToken: String cancellationToken: String
"""The eventual message the participant left"""
message: String
} }
enum ParticipantRoleEnum { enum ParticipantRoleEnum {
@ -1082,203 +1088,9 @@ enum ReportStatus {
} }
type RootMutationType { type RootMutationType {
saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings
changeEmail(email: String!, password: String!): User
"""Create a comment"""
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
"""Create an user"""
createUser(email: String!, locale: String, password: String!): User
"""Update an identity"""
updatePerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
id: ID!
"""The displayed name for this profile"""
name: String
"""The summary for this profile"""
summary: String
): Person
"""Create an event"""
createEvent(
beginsOn: DateTime!
category: String = "meeting"
description: String!
draft: Boolean = false
endsOn: DateTime
joinOptions: EventJoinOptions = FREE
onlineAddress: String
options: EventOptionsInput
organizerActorId: ID!
phoneAddress: String
physicalAddress: AddressInput
"""
The picture for the event, either as an object or directly the ID of an existing Picture
"""
picture: PictureInput
publishAt: DateTime
status: EventStatus
"""The list of tags associated to the event"""
tags: [String] = [""]
title: String!
visibility: EventVisibility = PUBLIC
): Event
validateEmail(token: String!): User
"""Delete an event"""
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
"""Accept a participation"""
updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
"""Leave an event"""
leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant
"""Delete an identity"""
deletePerson(id: ID!): Person
"""Refresh a token"""
refreshToken(refreshToken: String!): RefreshedToken
"""Validate an user after registration"""
validateUser(token: String!): Login
"""Upload a picture"""
uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
"""Delete a feed token"""
deleteFeedToken(token: String!): DeletedFeedToken
"""Create a note on a report"""
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
"""Leave an event"""
leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
"""Create a Feed Token"""
createFeedToken(actorId: ID): FeedToken
"""Send a link through email to reset user password""" """Send a link through email to reset user password"""
sendResetPassword(email: String!, locale: String): String sendResetPassword(email: String!, locale: String): String
"""Delete a relay subscription"""
removeRelay(address: String!): Follower
"""Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
"""Create a report"""
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
"""Register a first profile on registration"""
registerPerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The email from the user previously created"""
email: String!
"""The displayed name for the new profile"""
name: String = ""
preferredUsername: String!
"""The summary for the new profile"""
summary: String = ""
): Person
"""Delete a group"""
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
deleteAccount(password: String!): DeletedObject
"""Add a relay subscription"""
addRelay(address: String!): Follower
"""Reset user password"""
resetPassword(locale: String = "en", password: String!, token: String!): Login
"""Create a group"""
createGroup(
"""
The avatar for the group, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the group, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The identity that creates the group"""
creatorActorId: ID!
"""The displayed name for the group"""
name: String
"""The name for the group"""
preferredUsername: String!
"""The summary for the group"""
summary: String = ""
): Group
"""Confirm a participation"""
confirmParticipation(confirmationToken: String!): Participant
deleteComment(actorId: ID!, commentId: ID!): Comment
"""Join an event"""
joinEvent(actorId: ID!, email: String, eventId: ID!): Participant
"""Accept a relay subscription"""
acceptRelay(address: String!): Follower
"""Join a group"""
joinGroup(actorId: ID!, groupId: ID!): Member
"""Reject a relay subscription"""
rejectRelay(address: String!): Follower
"""Create a new person for user"""
createPerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The displayed name for the new profile"""
name: String = ""
preferredUsername: String!
"""The summary for the new profile"""
summary: String = ""
): Person
"""Update an event""" """Update an event"""
updateEvent( updateEvent(
beginsOn: DateTime beginsOn: DateTime
@ -1305,18 +1117,211 @@ type RootMutationType {
title: String title: String
visibility: EventVisibility = PUBLIC visibility: EventVisibility = PUBLIC
): Event ): Event
validateEmail(token: String!): User
"""Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User
"""Leave an event"""
leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant
deleteAccount(password: String!): DeletedObject
"""Delete a group"""
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
"""Create an user"""
createUser(email: String!, locale: String, password: String!): User
"""Add a relay subscription"""
addRelay(address: String!): Follower
"""Delete an identity"""
deletePerson(id: ID!): Person
"""Accept a relay subscription"""
acceptRelay(address: String!): Follower
"""Refresh a token"""
refreshToken(refreshToken: String!): RefreshedToken
"""Update an identity"""
updatePerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
id: ID!
"""The displayed name for this profile"""
name: String
"""The summary for this profile"""
summary: String
): Person
"""Create a report"""
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
"""Delete a feed token"""
deleteFeedToken(token: String!): DeletedFeedToken
"""Reset user password"""
resetPassword(locale: String = "en", password: String!, token: String!): Login
"""Login an user"""
login(email: String!, password: String!): Login
"""Leave an event"""
leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
"""Register a first profile on registration"""
registerPerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The email from the user previously created"""
email: String!
"""The displayed name for the new profile"""
name: String = ""
preferredUsername: String!
"""The summary for the new profile"""
summary: String = ""
): Person
"""Delete a relay subscription"""
removeRelay(address: String!): Follower
"""Change an user password""" """Change an user password"""
changePassword(newPassword: String!, oldPassword: String!): User changePassword(newPassword: String!, oldPassword: String!): User
"""Update a report"""
updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
"""Resend registration confirmation token""" """Resend registration confirmation token"""
resendConfirmationEmail(email: String!, locale: String): String resendConfirmationEmail(email: String!, locale: String): String
"""Login an user""" """Confirm a participation"""
login(email: String!, password: String!): Login confirmParticipation(confirmationToken: String!): Participant
"""Delete an event"""
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
"""Update a report"""
updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
"""Create a group"""
createGroup(
"""
The avatar for the group, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the group, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The identity that creates the group"""
creatorActorId: ID!
"""The displayed name for the group"""
name: String
"""The name for the group"""
preferredUsername: String!
"""The summary for the group"""
summary: String = ""
): Group
"""Validate an user after registration"""
validateUser(token: String!): Login
"""Join an event"""
joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
deleteComment(actorId: ID!, commentId: ID!): Comment
"""Reject a relay subscription"""
rejectRelay(address: String!): Follower
"""Create a comment"""
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
"""Create a note on a report"""
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
"""Accept a participation"""
updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
"""Create a Feed Token"""
createFeedToken(actorId: ID): FeedToken
"""Join a group"""
joinGroup(actorId: ID!, groupId: ID!): Member
"""Create a new person for user"""
createPerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The displayed name for the new profile"""
name: String = ""
preferredUsername: String!
"""The summary for the new profile"""
summary: String = ""
): Person
"""Upload a picture"""
uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
"""Create an event"""
createEvent(
beginsOn: DateTime!
category: String = "meeting"
description: String!
draft: Boolean = false
endsOn: DateTime
joinOptions: EventJoinOptions = FREE
onlineAddress: String
options: EventOptionsInput
organizerActorId: ID!
phoneAddress: String
physicalAddress: AddressInput
"""
The picture for the event, either as an object or directly the ID of an existing Picture
"""
picture: PictureInput
publishAt: DateTime
status: EventStatus
"""The list of tags associated to the event"""
tags: [String] = [""]
title: String!
visibility: EventVisibility = PUBLIC
): Event
saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings
changeEmail(email: String!, password: String!): User
} }
""" """

View File

@ -818,6 +818,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
assert activity.data["cc"] == [] assert activity.data["cc"] == []
end end
@join_message "I want to get in!"
test "it accepts Join activities" do test "it accepts Join activities" do
%Actor{url: organizer_url} = organizer = insert(:actor) %Actor{url: organizer_url} = organizer = insert(:actor)
%Actor{url: participant_url} = _participant = insert(:actor) %Actor{url: participant_url} = _participant = insert(:actor)
@ -829,13 +830,19 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
|> Jason.decode!() |> Jason.decode!()
|> Map.put("actor", participant_url) |> Map.put("actor", participant_url)
|> Map.put("object", event_url) |> Map.put("object", event_url)
|> Map.put("participationMessage", @join_message)
assert {:ok, activity, _} = Transmogrifier.handle_incoming(join_data) assert {:ok, activity, %Participant{} = participant} =
Transmogrifier.handle_incoming(join_data)
assert participant.metadata.message == @join_message
assert participant.role == :participant
assert activity.data["type"] == "Accept" assert activity.data["type"] == "Accept"
assert activity.data["object"]["object"] == event_url assert activity.data["object"]["object"] == event_url
assert activity.data["object"]["id"] =~ "/join/event/" assert activity.data["object"]["id"] =~ "/join/event/"
assert activity.data["object"]["type"] =~ "Join" assert activity.data["object"]["type"] =~ "Join"
assert activity.data["object"]["participationMessage"] == @join_message
assert activity.data["actor"] == organizer_url assert activity.data["actor"] == organizer_url
assert activity.data["id"] =~ "/accept/join/" assert activity.data["id"] =~ "/accept/join/"
end end
@ -894,6 +901,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
# Organiser is not present since we use factories directly # Organiser is not present since we use factories directly
assert event.id assert event.id
|> Events.list_participants_for_event() |> Events.list_participants_for_event()
|> Map.get(:elements)
|> Enum.map(& &1.id) == |> Enum.map(& &1.id) ==
[] []
end end
@ -924,6 +932,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
# The only participant left is the organizer # The only participant left is the organizer
assert event.id assert event.id
|> Events.list_participants_for_event() |> Events.list_participants_for_event()
|> Map.get(:elements)
|> Enum.map(& &1.id) == |> Enum.map(& &1.id) ==
[organizer_participation.id] [organizer_participation.id]
end end

View File

@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.{Event, EventParticipantStats, Participant} alias Mobilizon.Events.{Event, EventParticipantStats, Participant}
alias Mobilizon.GraphQL.AbsintheHelpers alias Mobilizon.GraphQL.AbsintheHelpers
alias Mobilizon.Storage.Page
alias Mobilizon.Web.Email alias Mobilizon.Web.Email
import Mobilizon.Factory import Mobilizon.Factory
@ -446,9 +447,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
participants(roles: "participant,moderator,administrator,creator", actor_id: "#{ participants(roles: "participant,moderator,administrator,creator", actor_id: "#{
actor.id actor.id
}") { }") {
role, elements {
actor { role,
preferredUsername actor {
preferredUsername
}
} }
} }
} }
@ -462,7 +465,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["event"]["participants"] == [ assert json_response(res, 200)["data"]["event"]["participants"]["elements"] == [
%{ %{
"actor" => %{ "actor" => %{
"preferredUsername" => actor.preferred_username "preferredUsername" => actor.preferred_username
@ -485,9 +488,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{ participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{
actor.id actor.id
}") { }") {
role, elements {
actor { role,
preferredUsername actor {
preferredUsername
}
} }
} }
} }
@ -500,7 +505,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants = sorted_participants =
json_response(res, 200)["data"]["event"]["participants"] json_response(res, 200)["data"]["event"]["participants"]["elements"]
|> Enum.filter(&(&1["role"] == "PARTICIPANT")) |> Enum.filter(&(&1["role"] == "PARTICIPANT"))
assert sorted_participants == [ assert sorted_participants == [
@ -518,9 +523,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{ participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{
actor.id actor.id
}") { }") {
role, elements {
actor { role,
preferredUsername actor {
preferredUsername
}
} }
} }
} }
@ -533,7 +540,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants = sorted_participants =
json_response(res, 200)["data"]["event"]["participants"] json_response(res, 200)["data"]["event"]["participants"]["elements"]
|> Enum.sort_by( |> Enum.sort_by(
&(&1 &(&1
|> Map.get("actor") |> Map.get("actor")
@ -1053,7 +1060,8 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id) assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id) assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id)
%Participant{} = participant = event.id |> Events.list_participants_for_event() |> hd %Participant{} =
participant = event.id |> Events.list_participants_for_event() |> Map.get(:elements) |> hd
assert participant.metadata.email == @email assert participant.metadata.email == @email
@ -1093,7 +1101,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert %Participant{ assert %Participant{
metadata: %{confirmation_token: confirmation_token}, metadata: %{confirmation_token: confirmation_token},
role: :not_confirmed role: :not_confirmed
} = participant = event.id |> Events.list_participants_for_event([]) |> hd() } =
participant =
event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd()
# hack to avoid preloading event in participant # hack to avoid preloading event in participant
participant = Map.put(participant, :event, event) participant = Map.put(participant, :event, event)
@ -1118,7 +1128,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
) )
assert %Participant{role: :participant} = assert %Participant{role: :participant} =
event.id |> Events.list_participants_for_event() |> hd() event.id |> Events.list_participants_for_event() |> Map.get(:elements) |> hd()
end end
test "I can participate anonymously and and confirm my participation with bad token", test "I can participate anonymously and and confirm my participation with bad token",
@ -1140,7 +1150,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id) assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id) assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id)
%Participant{} = participant = event.id |> Events.list_participants_for_event([]) |> hd %Participant{} =
participant =
event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
assert participant.metadata.email == @email assert participant.metadata.email == @email
@ -1157,7 +1169,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert hd(res["errors"])["message"] == "This token is invalid" assert hd(res["errors"])["message"] == "This token is invalid"
assert %Participant{role: :not_confirmed} = assert %Participant{role: :not_confirmed} =
event.id |> Events.list_participants_for_event([]) |> hd() event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd()
end end
test "I can participate anonymously but change my mind and cancel my participation", test "I can participate anonymously but change my mind and cancel my participation",
@ -1181,7 +1193,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
{:ok, %Event{participant_stats: %{not_confirmed: 1}}} = Events.get_event(event.id) {:ok, %Event{participant_stats: %{not_confirmed: 1}}} = Events.get_event(event.id)
%Participant{} = participant = event.id |> Events.list_participants_for_event([]) |> hd %Participant{} =
participant =
event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
assert participant.metadata.email == @email assert participant.metadata.email == @email
@ -1205,7 +1219,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
id: participant_id, id: participant_id,
role: :not_confirmed, role: :not_confirmed,
metadata: %{cancellation_token: cancellation_token} metadata: %{cancellation_token: cancellation_token}
} = event.id |> Events.list_participants_for_event([]) |> hd() } = event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd()
res = res =
conn conn
@ -1221,7 +1235,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert res["data"]["leaveEvent"]["id"] == participant_id assert res["data"]["leaveEvent"]["id"] == participant_id
{:ok, %Event{participant_stats: %{not_confirmed: 0}}} = Events.get_event(event.id) {:ok, %Event{participant_stats: %{not_confirmed: 0}}} = Events.get_event(event.id)
assert Events.list_participants_for_event(event.id, []) == [] assert Events.list_participants_for_event(event.id, []) == %Page{elements: [], total: 0}
end end
test "I can participate anonymously, confirm my participation and then be confirmed by the organizer", test "I can participate anonymously, confirm my participation and then be confirmed by the organizer",
@ -1274,7 +1288,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert %Participant{ assert %Participant{
role: :not_confirmed, role: :not_confirmed,
metadata: %{confirmation_token: confirmation_token, email: @email} metadata: %{confirmation_token: confirmation_token, email: @email}
} = event.id |> Events.list_participants_for_event([]) |> hd } = event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
conn conn
|> AbsintheHelpers.graphql_query( |> AbsintheHelpers.graphql_query(
@ -1293,7 +1307,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
}} = Events.get_event(event.id) }} = Events.get_event(event.id)
assert %Participant{role: :not_approved, id: participant_id} = assert %Participant{role: :not_approved, id: participant_id} =
event.id |> Events.list_participants_for_event([]) |> hd event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
update_participation_mutation = """ update_participation_mutation = """
mutation UpdateParticipation($participantId: ID!, $role: String!, $moderatorActorId: ID!) { mutation UpdateParticipation($participantId: ID!, $role: String!, $moderatorActorId: ID!) {
@ -1325,7 +1339,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert res["errors"] == nil assert res["errors"] == nil
assert %Participant{role: :participant} = assert %Participant{role: :participant} =
event.id |> Events.list_participants_for_event([]) |> hd event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
assert {:ok, assert {:ok,
%Event{ %Event{

View File

@ -110,8 +110,11 @@ defmodule Mobilizon.EventsTest do
assert event.ends_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC") assert event.ends_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC")
assert event.title == "some title" assert event.title == "some title"
assert hd(Events.list_participants_for_event(event.id)).actor.id == actor.id assert %Participant{} =
assert hd(Events.list_participants_for_event(event.id)).role == :creator participant = hd(Events.list_participants_for_event(event.id).elements)
assert participant.actor.id == actor.id
assert participant.role == :creator
end end
test "create_event/1 with invalid data returns error changeset" do test "create_event/1 with invalid data returns error changeset" do