Add a dropdown on participate menu, disallow listing participations

Now requires quering the person endpoint to know if an actor
participates in an event, organizers can make authenticated requests to
event { participants { } } to see the pending / approved participants.

Also closes #174

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-09-26 16:38:58 +02:00
parent 8a3e606c15
commit 757d2cabec
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
34 changed files with 655 additions and 439 deletions

View File

@ -71,49 +71,10 @@ export default class App extends Vue {
@import "variables";
/* Bulma imports */
@import "~bulma/sass/utilities/_all";
@import "~bulma/sass/base/_all.sass";
@import "~bulma/sass/components/card.sass";
@import "~bulma/sass/components/media.sass";
@import "~bulma/sass/components/message.sass";
@import "~bulma/sass/components/modal.sass";
@import "~bulma/sass/components/navbar.sass";
@import "~bulma/sass/components/pagination.sass";
@import "~bulma/sass/components/dropdown.sass";
@import "~bulma/sass/components/breadcrumb.sass";
@import "~bulma/sass/components/list.sass";
@import "~bulma/sass/components/tabs";
@import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass";
@import "~bulma/sass/form/_all";
@import "~bulma/sass/elements/icon.sass";
@import "~bulma/sass/elements/image.sass";
@import "~bulma/sass/elements/other.sass";
@import "~bulma/sass/elements/progress.sass";
@import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/notification";
@import "~bulma/sass/elements/table";
@import "~bulma/sass/grid/_all.sass";
@import "~bulma/sass/layout/_all.sass";
@import "~bulma/bulma";
/* Buefy imports */
@import "~buefy/src/scss/utils/_all";
@import "~buefy/src/scss/components/datepicker";
@import "~buefy/src/scss/components/notices";
@import "~buefy/src/scss/components/dropdown";
@import "~buefy/src/scss/components/autocomplete";
@import "~buefy/src/scss/components/form";
@import "~buefy/src/scss/components/modal";
@import "~buefy/src/scss/components/progress";
@import "~buefy/src/scss/components/tag";
@import "~buefy/src/scss/components/taginput";
@import "~buefy/src/scss/components/upload";
@import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch";
@import "~buefy/src/scss/components/table";
@import "~buefy/src/scss/components/tabs";
@import "~buefy/src/scss/buefy";
.router-enter-active,
.router-leave-active {

View File

@ -6,7 +6,7 @@
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
@Component({})
@Component
export default class DateTimePicker extends Vue {
@Prop({ required: true, type: Date }) value!: Date;
@Prop({ required: false, type: String, default: 'Datetime' }) label!: string;

View File

@ -1,64 +1,66 @@
<template>
<article class="box columns">
<div class="content column">
<div class="title-wrapper">
<div class="date-component" v-if="!mergedOptions.hideDate">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<h2 class="title" ref="title">{{ participation.event.title }}</h2>
</div>
<div>
<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-else>
<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>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon icon="earth" v-if=" participation.event.visibility === EventVisibility.PUBLIC" />
<b-icon icon="lock_opened" v-if=" participation.event.visibility === EventVisibility.RESTRICTED" />
<b-icon icon="lock" v-if=" participation.event.visibility === EventVisibility.PRIVATE" />
</span>
<span class="column">
<span v-if="!participation.event.options.maximumAttendeeCapacity">
{{ $tc('{count} participants', participation.event.participantStats.approved, { count: participation.event.participantStats.approved })}}
</span>
<b-progress
v-if="participation.event.options.maximumAttendeeCapacity > 0"
type="is-primary"
size="is-medium"
:value="participation.event.participantStats.approved * 100 / participation.event.options.maximumAttendeeCapacity" show-value>
{{ $t('{approved} / {total} seats', {approved: participation.event.participantStats.approved, total: participation.event.options.maximumAttendeeCapacity }) }}
</b-progress>
<span
v-if="participation.event.participantStats.unapproved > 0">
{{ $tc('{count} requests waiting', participation.event.participantStats.unapproved, { count: participation.event.participantStats.unapproved })}}
</span>
</span>
<article class="box">
<div class="title-wrapper">
<div class="date-component" v-if="!mergedOptions.hideDate">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<h2 class="title" ref="title">{{ participation.event.title }}</h2>
</div>
<div class="actions column is-narrow">
<ul>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.EDIT_EVENT, params: { eventId: participation.event.uuid } }">
<b-icon icon="pencil" /> {{ $t('Edit') }}
</router-link>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } }">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</router-link>
</li>
<li>
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link>
</li>
</ul>
<div class="columns">
<div class="content column">
<div>
<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-else>
<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>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon icon="earth" v-if=" participation.event.visibility === EventVisibility.PUBLIC" />
<b-icon icon="lock_opened" v-if=" participation.event.visibility === EventVisibility.RESTRICTED" />
<b-icon icon="lock" v-if=" participation.event.visibility === EventVisibility.PRIVATE" />
</span>
<span class="column">
<span v-if="!participation.event.options.maximumAttendeeCapacity">
{{ $tc('{count} participants', participation.event.participantStats.approved, { count: participation.event.participantStats.approved })}}
</span>
<b-progress
v-if="participation.event.options.maximumAttendeeCapacity > 0"
type="is-primary"
size="is-medium"
:value="participation.event.participantStats.approved * 100 / participation.event.options.maximumAttendeeCapacity" show-value>
{{ $t('{approved} / {total} seats', {approved: participation.event.participantStats.approved, total: participation.event.options.maximumAttendeeCapacity }) }}
</b-progress>
<span
v-if="participation.event.participantStats.unapproved > 0">
{{ $tc('{count} requests waiting', participation.event.participantStats.unapproved, { count: participation.event.participantStats.unapproved })}}
</span>
</span>
</div>
</div>
<div class="actions column is-narrow">
<ul>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.EDIT_EVENT, params: { eventId: participation.event.uuid } }">
<b-icon icon="pencil" /> {{ $t('Edit') }}
</router-link>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } }">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</router-link>
</li>
<li>
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link>
</li>
</ul>
</div>
</div>
</article>
</template>

View File

@ -0,0 +1,111 @@
<template>
<div class="participation-button">
<b-dropdown aria-role="list" position="is-bottom-left" v-if="participation && participation.role === ParticipantRole.PARTICIPANT">
<button class="button is-success" type="button" slot="trigger">
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
<!-- {{ $t('Change my identity…')}}-->
<!-- </b-dropdown-item>-->
<b-dropdown-item :value="false" aria-role="listitem" @click="confirmLeave">
{{ $t('Cancel my participation…')}}
</b-dropdown-item>
</b-dropdown>
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED">
<b-dropdown aria-role="list" position="is-bottom-left" class="dropdown-disabled">
<button class="button is-success" type="button" slot="trigger">
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
<!-- {{ $t('Change my identity…')}}-->
<!-- </b-dropdown-item>-->
<b-dropdown-item :value="false" aria-role="listitem" @click="confirmLeave">
{{ $t('Cancel my participation request…')}}
</b-dropdown-item>
</b-dropdown>
<small>{{ $t('Participation requested!')}}</small><br />
<small>{{ $t('Waiting for organization team approval.')}}</small>
</div>
<b-dropdown aria-role="list" position="is-bottom-left" v-if="!participation">
<button class="button is-primary" type="button" slot="trigger">
<template>
<span>{{ $t('Participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
<b-dropdown-item :value="true" aria-role="listitem" @click="joinEvent(currentActor)">
<div class="media">
<div class="media-left">
<figure class="image is-32x32" v-if="currentActor.avatar">
<img class="is-rounded" :src="currentActor.avatar.url" alt="" />
</figure>
</div>
<div class="media-content">
<span>{{ $t('with {identity}', {identity: currentActor.preferredUsername }) }}</span>
</div>
</div>
</b-dropdown-item>
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal">
{{ $t('with another identity…')}}
</b-dropdown-item>
</b-dropdown>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
@Component
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) currentActor!: IPerson;
ParticipantRole = ParticipantRole;
joinEvent(actor: IPerson) {
this.$emit('joinEvent', actor);
}
joinModal() {
this.$emit('joinModal');
}
confirmLeave() {
this.$emit('confirmLeave');
}
}
</script>
<style lang="scss">
.participation-button {
.dropdown {
display: flex;
justify-content: flex-end;
&.dropdown-disabled button {
opacity: 0.5;
}
}
button {
font-size: 1.5rem;
}
}
</style>

View File

@ -1,92 +0,0 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Join event {title}', {title: event.title}) }}</p>
</header>
<section class="modal-card-body is-flex">
<div class="media">
<div
class="media-left">
<b-icon
icon="alert"
type="is-warning"
size="is-large"/>
</div>
<div class="media-content">
<p>{{ $t('Do you want to participate in {title}?', {title: event.title}) }}?</p>
<b-field :label="$t('Identity')">
<identity-picker v-model="identity"></identity-picker>
</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">
{{ $t('The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.') }}
</p>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="close">
{{ $t('Cancel') }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="confirm">
{{ $t('Confirm my particpation') }}
</button>
</footer>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent, EventJoinOptions } from '@/types/event.model';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import { IPerson } from '@/types/actor';
@Component({
components: {
IdentityPicker,
},
mounted() {
this.$data.isActive = true;
},
})
export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: Object }) event! : IEvent;
@Prop({ type: Object }) defaultIdentity!: IPerson;
isActive: boolean = false;
identity: IPerson = this.defaultIdentity;
EventJoinOptions = EventJoinOptions;
confirm() {
this.onConfirm(this.identity);
}
/**
* Close the Dialog.
*/
close() {
this.isActive = false;
this.$emit('close');
}
}
</script>
<style lang="scss">
.modal-card .modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@ -10,6 +10,9 @@ const participantQuery = `
},
name,
id
},
event {
id
}
`;
@ -52,7 +55,7 @@ const optionsQuery = `
`;
export const FETCH_EVENT = gql`
query($uuid:UUID!, $roles: String) {
query($uuid:UUID!) {
event(uuid: $uuid) {
id,
uuid,
@ -95,9 +98,6 @@ export const FETCH_EVENT = gql`
# preferredUsername,
# name,
# },
participants (roles: $roles) {
${participantQuery}
},
participantStats {
approved,
unapproved
@ -363,9 +363,10 @@ export const DELETE_EVENT = gql`
`;
export const PARTICIPANTS = gql`
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String, $actorId: ID!) {
event(uuid: $uuid) {
participants(page: $page, limit: $limit, roles: $roles) {
id,
participants(page: $page, limit: $limit, roles: $roles, actorId: $actorId) {
${participantQuery}
},
participantStats {
@ -375,3 +376,21 @@ export const PARTICIPANTS = gql`
}
}
`;
export const EVENT_PERSON_PARTICIPATION = gql`
query($name: String!, $eventId: ID!) {
person(preferredUsername: $name) {
id,
participations(eventId: $eventId) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
`;

View File

@ -17,8 +17,13 @@
"Are you sure you want to delete this event? This action cannot be reverted.": "Are you sure you want to delete this event? This action cannot be reverted.",
"Before you can login, you need to click on the link inside it to validate your account": "Before you can login, you need to click on the link inside it to validate your account",
"By {name}": "By {name}",
"Cancel my participation request…": "Cancel my participation request…",
"Cancel my participation…": "Cancel my participation…",
"Cancel": "Cancel",
"Category": "Category",
"Change my identity…": "Change my identity…",
"Change my password": "Change my password",
"Change password": "Change password",
"Change": "Change",
"Clear": "Clear",
"Click to select": "Click to select",
@ -82,6 +87,7 @@
"Group": "Group",
"Groups": "Groups",
"I create an identity": "I create an identity",
"I participate": "I participate",
"I want to approve every participation request": "I want to approve every participation request",
"Identities": "Identities",
"Identity {displayName} created": "Identity {displayName} created",
@ -116,6 +122,7 @@
"My events": "My events",
"My identities": "My identities",
"Name": "Name",
"New password": "New password",
"No address defined": "No address defined",
"No events found": "No events found",
"No group found": "No group found",
@ -123,6 +130,7 @@
"No participants yet.": "No participants yet.",
"No results for \"{queryText}\"": "No results for \"{queryText}\"",
"Number of places": "Number of places",
"Old password": "Old password",
"One person is going": "No one is going | One person is going | {approved} persons are going",
"Only accessible through link and search (private)": "Only accessible through link and search (private)",
"Opened reports": "Opened reports",
@ -133,8 +141,11 @@
"Otherwise this identity will just be removed from the group administrators.": "Otherwise this identity will just be removed from the group administrators.",
"Page limited to my group (asks for auth)": "Page limited to my group (asks for auth)",
"Participants": "Participants",
"Participate": "Participate",
"Participation approval": "Participation approval",
"Participation requested!": "Participation requested!",
"Password (confirmation)": "Password (confirmation)",
"Password change": "Password change",
"Password reset": "Password reset",
"Password": "Password",
"Past events": "Passed events",
@ -187,6 +198,7 @@
"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 title will be ellipsed.": "The event title will be ellipsed.",
"The page you're looking for doesn't exist.": "The page you're looking for doesn't exist.",
"The password was successfully changed": "The password was successfully changed",
"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.",
"The {date} at {time}": "The {date} at {time}",
"The {date} from {startTime} to {endTime}": "The {date} from {startTime} to {endTime}",
@ -208,6 +220,7 @@
"View event page": "View event page",
"View everything": "View everything",
"Visible everywhere on the web (public)": "Visible everywhere on the web (public)",
"Waiting for organization team approval.": "Waiting for organization team approval.",
"Waiting list": "Waiting list",
"We just sent an email to {email}": "We just sent an email to {email}",
"Website / URL": "Website / URL",
@ -220,6 +233,7 @@
"You announced that you're going to this event.": "You announced that you're going to this event.",
"You are already logged-in.": "You are already logged-in.",
"You are an organizer.": "You are an organizer.",
"You have been disconnected": "You have been disconnected",
"You have one event in {days} days.": "You have no events in {days} days | You have one event in {days} days. | You have {count} events in {days} days",
"You have one event today.": "You have no events today | You have one event today. | You have {count} events today",
"You have one event tomorrow.": "You have no events tomorrow | You have one event tomorrow. | You have {count} events tomorrow",
@ -233,6 +247,8 @@
"e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot",
"iCal Feed": "iCal Feed",
"meditate a bit": "meditate a bit",
"with another identity…": "with another identity…",
"with {identity}": "with {identity}",
"{actor}'s avatar": "{actor}'s avatar",
"{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "{count} participants",

View File

@ -17,8 +17,13 @@
"Are you sure you want to delete this event? This action cannot be reverted.": "Êtes-vous certain⋅e de vouloir supprimer cet événement ? Cette action ne peut être annulée.",
"Before you can login, you need to click on the link inside it to validate your account": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte",
"By {name}": "Par {name}",
"Cancel my participation request…": "Cancel my participation request…",
"Cancel my participation…": "Annuler ma participation…",
"Cancel": "Annuler",
"Category": "Catégorie",
"Change my identity…": "Changer mon identité…",
"Change my password": "Modifier mon mot de passe",
"Change password": "Modifier mot de passe",
"Change": "Modifier",
"Clear": "Effacer",
"Click to select": "Cliquez pour sélectionner",
@ -82,6 +87,7 @@
"Group": "Groupe",
"Groups": "Groupes",
"I create an identity": "Je crée une identité",
"I participate": "Je participe",
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
"Identities": "Identités",
"Identity {displayName} created": "Identité {displayName} créée",
@ -116,6 +122,7 @@
"My events": "Mes événements",
"My identities": "Mes identités",
"Name": "Nom",
"New password": "Nouveau mot de passe",
"No address defined": "Aucune adresse définie",
"No events found": "Aucun événement trouvé",
"No group found": "Aucun groupe trouvé",
@ -123,6 +130,7 @@
"No participants yet.": "Pas de participants pour le moment.",
"No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »",
"Number of places": "Nombre de places",
"Old password": "Ancien mot de passe",
"One person is going": "Personne n'y va | Une personne y va | {approved} personnes y vont",
"Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)",
"Opened reports": "Signalements ouverts",
@ -133,8 +141,11 @@
"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)",
"Participants": "Participants",
"Participate": "Participer",
"Participation approval": "Validation des participations",
"Participation requested!": "Participation demandée !",
"Password (confirmation)": "Mot de passe (confirmation)",
"Password change": "Changement de mot de passe",
"Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe",
"Past events": "Événements passés",
@ -187,6 +198,7 @@
"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 title will be ellipsed.": "Le titre de l'événement sera ellipsé.",
"The page you're looking for doesn't exist.": "La page que vous recherchez n'existe pas.",
"The password was successfully changed": "Le mot de passe a été changé avec succès",
"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.",
"The {date} at {time}": "Le {date} à {time}",
"The {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}",
@ -208,6 +220,7 @@
"View event page": "Voir la page de l'événement",
"View everything": "Voir tout",
"Visible everywhere on the web (public)": "Visible partout sur le web (public)",
"Waiting for organization team approval.": "En attente d'approbation par l'organisation.",
"Waiting list": "Liste d'attente",
"We just sent an email to {email}": "Nous venons d'envoyer un email à {email}",
"Website / URL": "Site web / URL",
@ -220,6 +233,7 @@
"You announced that you're going to this event.": "Vous avez annoncé vous rendre à cet événement.",
"You are already logged-in.": "Vous êtes déjà connecté.",
"You are an organizer.": "Vous êtes un organisateur.",
"You have been disconnected": "Vous avez été déconnecté⋅e",
"You have one event in {days} days.": "Vous n'avez pas d'événements dans {days} jours | Vous avez un événement dans {days} jours. | Vous avez {count} événements dans {days} jours",
"You have one event today.": "Vous n'avez pas d'évenement aujourd'hui | Vous avez un événement aujourd'hui. | Vous avez {count} événements aujourd'hui",
"You have one event tomorrow.": "Vous n'avez pas d'événement demain | Vous avez un événement demain. | Vous avez {count} événements demain",
@ -233,6 +247,8 @@
"e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot",
"iCal Feed": "Flux iCal",
"meditate a bit": "méditez un peu",
"with another identity…": "avec une autre identité…",
"with {identity}": "avec {identity}",
"{actor}'s avatar": "Avatar de {actor}",
"{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Un⋅e participant⋅e|{count} participant⋅e⋅s",

View File

@ -1,5 +1,5 @@
import { ICurrentUser } from '@/types/current-user.model';
import { IEvent } from '@/types/event.model';
import { IEvent, IParticipant } from '@/types/event.model';
import { Actor, IActor } from '@/types/actor/actor.model';
export interface IFeedToken {
@ -11,11 +11,13 @@ export interface IFeedToken {
export interface IPerson extends IActor {
feedTokens: IFeedToken[];
goingToEvents: IEvent[];
participations: IParticipant[];
}
export class Person extends Actor implements IPerson {
feedTokens: IFeedToken[] = [];
goingToEvents: IEvent[] = [];
participations: IParticipant[] = [];
constructor(hash: IPerson | {} = {}) {
super(hash);

View File

@ -1,29 +1,22 @@
<template>
<div class="identity-picker">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Pick an identity') }}</p>
</header>
<section class="modal-card-body">
<div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media">
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" />
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
<small>{{ identity.name }}</small>
</div>
</div>
</a>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Pick an identity') }}</p>
</header>
<section class="modal-card-body">
<div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media">
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" />
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
<small>{{ identity.name }}</small>
</div>
</div>
</section>
</a>
</div>
</b-modal>
</section>
<slot name="footer"></slot>
</div>
</template>
<script lang="ts">
@ -40,22 +33,13 @@ import { IDENTITIES } from '@/graphql/actor';
})
export default class IdentityPicker extends Vue {
@Prop() value!: IActor;
isComponentModalActive: boolean = false;
identities: IActor[] = [];
currentIdentity: IActor = this.value;
changeCurrentIdentity(identity: IActor) {
this.currentIdentity = identity;
this.$emit('input', identity);
this.isComponentModalActive = false;
}
}
</script>
<style lang="scss">
.identity-picker img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>
</script>

View File

@ -0,0 +1,39 @@
<template>
<div class="identity-picker">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<identity-picker :currentIdentity="currentIdentity" @input="relay" />
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor } from '@/types/actor';
import IdentityPicker from './IdentityPicker.vue';
@Component({
components: { IdentityPicker },
})
export default class IdentityPickerWrapper extends Vue {
@Prop() value!: IActor;
isComponentModalActive: boolean = false;
currentIdentity: IActor = this.value;
relay(identity: IActor) {
this.currentIdentity = identity;
this.$emit('input', identity);
this.isComponentModalActive = false;
}
}
</script>
<style lang="scss">
.identity-picker img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>

View File

@ -92,6 +92,7 @@ export default class Register extends Vue {
domain: null,
feedTokens: [],
goingToEvents: [],
participations: [],
};
errors: object = {};
validationSent: boolean = false;

View File

@ -29,7 +29,7 @@ import {EventJoinOptions} from "@/types/event.model";
<address-auto-complete v-model="event.physicalAddress" />
<b-field :label="$t('Organizer')">
<identity-picker v-model="event.organizerActor"></identity-picker>
<identity-picker-wrapper v-model="event.organizerActor"></identity-picker-wrapper>
</b-field>
<div class="field">
@ -188,7 +188,6 @@ import {
EventModel,
EventStatus,
EventVisibility,
EventVisibilityJoinOptions,
} from '@/types/event.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { Person } from '@/types/actor';
@ -200,10 +199,10 @@ import { TAGS } from '@/graphql/tags';
import { ITag } from '@/types/tag.model';
import AddressAutoComplete from '@/components/Event/AddressAutoComplete.vue';
import { buildFileFromIPicture, buildFileVariable } from '@/utils/image';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
@Component({
components: { AddressAutoComplete, TagInput, DateTimePicker, PictureUpload, Editor, IdentityPicker },
components: { IdentityPickerWrapper, AddressAutoComplete, TagInput, DateTimePicker, PictureUpload, Editor },
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,

View File

@ -1,3 +1,6 @@
import {ParticipantRole} from "@/types/event.model";
import {ParticipantRole} from "@/types/event.model";
import {ParticipantRole} from "@/types/event.model";
<template>
<div>
<b-loading :active.sync="$apollo.loading"></b-loading>
@ -10,7 +13,7 @@
<img src="https://picsum.photos/600/200/">
</figure>
</div>
<section class="container">
<section>
<div class="title-and-participate-button">
<div class="title-wrapper">
<div class="date-component">
@ -18,21 +21,21 @@
</div>
<h1 class="title">{{ event.title }}</h1>
</div>
<span v-if="event.participantStats.approved > 0 && !actorIsParticipant()">
{{ $tc('One person is going', event.participantStats.approved, {approved: event.participantStats.approved}) }}
</span>
<span v-else>
{{ $tc('You and one other person are going to this event', event.participantStats.approved - 1, {approved: event.participantStats.approved - 1}) }}
</span>
<div v-if="!actorIsOrganizer()" class="participate-button has-text-centered">
<a v-if="!actorIsParticipant()" @click="isJoinModalActive = true" class="button is-large is-primary is-rounded">
<b-icon icon="circle-outline"></b-icon>
{{ $t('Join') }}
</a>
<a v-if="actorIsParticipant()" @click="confirmLeave()" class="button is-large is-primary is-rounded">
<b-icon icon="check-circle"></b-icon>
{{ $t('Leave') }}
</a>
<div class="has-text-right">
<small v-if="event.participantStats.approved > 0 && !actorIsParticipant">
{{ $tc('One person is going', event.participantStats.approved, {approved: event.participantStats.approved}) }}
</small>
<small v-else>
{{ $tc('You and one other person are going to this event', event.participantStats.approved - 1, {approved: event.participantStats.approved - 1}) }}
</small>
<participation-button
v-if="currentActor.id && !actorIsOrganizer"
:participation="participations[0]"
:current-actor="currentActor"
@joinEvent="joinEvent"
@joinModal="isJoinModalActive = true"
@confirmLeave="confirmLeave"
/>
</div>
</div>
<div class="metadata columns">
@ -60,8 +63,8 @@
</p>
</div>
<div class="column sidebar">
<div class="field has-addons">
<p class="control" v-if="actorIsOrganizer()">
<div class="field has-addons" v-if="currentActor.id">
<p class="control" v-if="actorIsOrganizer">
<router-link
class="button"
:to="{ name: 'EditEvent', params: {eventId: event.uuid}}"
@ -69,7 +72,7 @@
{{ $t('Edit') }}
</router-link>
</p>
<p class="control" v-if="actorIsOrganizer()">
<p class="control" v-if="actorIsOrganizer">
<a class="button is-danger" @click="openDeleteEventModalWrapper">
{{ $t('Delete') }}
</a>
@ -133,26 +136,6 @@
</div>
</div>
</div>
<section class="container">
<h3 class="title">{{ $t('Participants') }}</h3>
<router-link v-if="currentActor.id === event.organizerActor.id" :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: event.uuid } }">
{{ $t('Manage participants') }}
</router-link>
<span v-if="event.participants.length === 0">{{ $t('No participants yet.') }}</span>
<div class="columns">
<div
class="column"
v-for="participant in event.participants"
:key="participant.id"
>
<figure class="image is-48x48">
<img v-if="!participant.actor.avatar.url" src="https://picsum.photos/48/48/" class="is-rounded">
<img v-else :src="participant.actor.avatar.url" class="is-rounded">
</figure>
<span>{{ participant.actor.preferredUsername }}</span>
</div>
</div>
</section>
<section class="share">
<div class="container">
<div class="columns">
@ -188,19 +171,35 @@
<report-modal :on-confirm="reportEvent" :title="$t('Report this event')" :outside-domain="event.organizerActor.domain" @close="$refs.reportModal.close()" />
</b-modal>
<b-modal :active.sync="isJoinModalActive" has-modal-card ref="participationModal">
<participation-modal :on-confirm="joinEvent" :event="event" :defaultIdentity="currentActor" @close="$refs.participationModal.close()" />
<identity-picker v-model="identity">
<template v-slot:footer>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="isJoinModalActive = false">
{{ $t('Cancel') }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="joinEvent(identity)">
{{ $t('Confirm my particpation') }}
</button>
</footer>
</template>
</identity-picker>
</b-modal>
</div>
</div>
</template>
<script lang="ts">
import { DELETE_EVENT, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
import { RouteName } from '@/router';
import { IPerson, Person } from '@/types/actor';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import BIcon from 'buefy/src/components/icon/Icon.vue';
@ -208,11 +207,11 @@ import EventCard from '@/components/Event/EventCard.vue';
import EventFullDate from '@/components/Event/EventFullDate.vue';
import ActorLink from '@/components/Account/ActorLink.vue';
import ReportModal from '@/components/Report/ReportModal.vue';
import ParticipationModal from '@/components/Event/ParticipationModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
import EventMixin from '@/mixins/event';
import { EventRouteName } from '@/router/event';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
@Component({
components: {
@ -222,7 +221,8 @@ import { EventRouteName } from '@/router/event';
BIcon,
DateCalendarIcon,
ReportModal,
ParticipationModal,
IdentityPicker,
ParticipationButton,
// tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable
@ -233,13 +233,25 @@ import { EventRouteName } from '@/router/event';
variables() {
return {
uuid: this.uuid,
roles: [ParticipantRole.CREATOR, ParticipantRole.MODERATOR, ParticipantRole.MODERATOR, ParticipantRole.PARTICIPANT].join(),
};
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
variables() {
return {
eventId: this.event.id,
name: this.currentActor.preferredUsername,
};
},
update: (data) => {
if (data && data.person) return data.person.participations;
return [];
},
},
},
})
export default class Event extends EventMixin {
@ -247,13 +259,17 @@ export default class Event extends EventMixin {
event!: IEvent;
currentActor!: IPerson;
validationSent: boolean = false;
identity: IPerson = new Person();
participations: IParticipant[] = [];
showMap: boolean = false;
isReportModalActive: boolean = false;
isJoinModalActive: boolean = false;
EventVisibility = EventVisibility;
EventRouteName = EventRouteName;
mounted() {
this.identity = this.currentActor;
}
/**
* Delete the event, then redirect to home.
@ -298,6 +314,24 @@ export default class Event extends EventMixin {
},
update: (store, { data }) => {
if (data == null) return;
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: identity.preferredUsername },
});
if (participationCachedData == null) return;
const { person } = participationCachedData;
if (person === null) {
console.error('Cannot update participation cache, because of null value.');
return;
}
person.participations.push(data.joinEvent);
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: identity.preferredUsername },
data: { person },
});
const cachedData = store.readQuery<{ event: IEvent }>({ query: FETCH_EVENT, variables: { uuid: this.event.uuid } });
if (cachedData == null) return;
const { event } = cachedData;
@ -306,9 +340,13 @@ export default class Event extends EventMixin {
return;
}
event.participants = event.participants.concat([data.joinEvent]);
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
event.participantStats.unapproved = event.participantStats.unapproved + 1;
} else {
event.participantStats.approved = event.participantStats.approved + 1;
}
store.writeQuery({ query: FETCH_EVENT, data: { event } });
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.uuid }, data: { event } });
},
});
} catch (error) {
@ -338,19 +376,38 @@ export default class Event extends EventMixin {
},
update: (store, { data }) => {
if (data == null) return;
const cachedData = store.readQuery<{ event: IEvent }>({ query: FETCH_EVENT, variables: { uuid: this.event.uuid } });
if (cachedData == null) return;
const { event } = cachedData;
if (event === null) {
console.error('Cannot update event participant cache, because of null value.');
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: this.currentActor.preferredUsername },
});
if (participationCachedData == null) return;
const { person } = participationCachedData;
if (person === null) {
console.error('Cannot update participation cache, because of null value.');
return;
}
const participation = person.participations[0];
person.participations = [];
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: this.currentActor.preferredUsername },
data: { person },
});
event.participants = event.participants
.filter(p => p.actor.id !== data.leaveEvent.actor.id);
event.participantStats.approved = event.participantStats.approved - 1;
store.writeQuery({ query: FETCH_EVENT, data: { event } });
const eventCachedData = store.readQuery<{ event: IEvent }>({ query: FETCH_EVENT, variables: { uuid: this.event.uuid } });
if (eventCachedData == null) return;
const { event } = eventCachedData;
if (event === null) {
console.error('Cannot update event cache, because of null value.');
return;
}
if (participation.role === ParticipantRole.NOT_APPROVED) {
event.participantStats.unapproved = event.participantStats.unapproved - 1;
} else {
event.participantStats.approved = event.participantStats.approved - 1;
}
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.uuid }, data: { event } });
},
});
} catch (error) {
@ -369,17 +426,14 @@ export default class Event extends EventMixin {
document.body.removeChild(link);
}
actorIsParticipant() {
if (this.actorIsOrganizer()) return true;
get actorIsParticipant() {
if (this.actorIsOrganizer) return true;
return this.currentActor &&
this.event.participants
.some(participant => participant.actor.id === this.currentActor.id);
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.PARTICIPANT;
}
actorIsOrganizer() {
return this.currentActor && this.event.organizerActor &&
this.currentActor.id === this.event.organizerActor.id;
get actorIsOrganizer() {
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
}
get twitterShareUrl(): string {

View File

@ -68,6 +68,7 @@ import { IPerson } from '@/types/actor';
page: 1,
limit: 10,
roles: [ParticipantRole.PARTICIPANT].join(),
actorId: this.currentActor.id,
};
},
},
@ -79,6 +80,7 @@ import { IPerson } from '@/types/actor';
page: 1,
limit: 20,
roles: [ParticipantRole.CREATOR].join(),
actorId: this.currentActor.id,
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),
@ -91,6 +93,7 @@ import { IPerson } from '@/types/actor';
page: 1,
limit: 20,
roles: [ParticipantRole.NOT_APPROVED].join(),
actorId: this.currentActor.id,
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),

View File

@ -35,7 +35,6 @@
<h3 class="title">
{{ $t("Upcoming") }}
</h3>
<pre>{{ Array.from(goingToEvents.entries()) }}</pre>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div v-for="row in goingToEvents" class="upcoming-events">
<span class="date-component-container" v-if="isInLessThanSevenDays(row[0])">
@ -53,13 +52,12 @@
{{ $tc('You have one event in {days} days.', row[1].length, {count: row[1].length, days: calculateDiffDays(row[0])}) }}
</h3>
</span>
<div class="level">
<div>
<EventListCard
v-for="participation in row[1]"
v-if="isInLessThanSevenDays(row[0])"
:key="participation[1].event.uuid"
:participation="participation[1]"
class="level-item"
/>
</div>
</div>
@ -72,12 +70,11 @@
{{ $t("Last week") }}
</h3>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div class="level">
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
class="level-item"
:options="{ hideDate: false }"
/>
</div>
@ -295,12 +292,6 @@ export default class Home extends Vue {
}
}
.upcoming-events {
.level {
margin-left: 4rem;
}
}
section.container {
margin: auto auto 3rem;
}

View File

@ -169,7 +169,7 @@ export default class Report extends Vue {
report.notes = report.notes.concat([note]);
store.writeQuery({ query: REPORT, data: { report } });
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
},
});
@ -235,7 +235,7 @@ export default class Report extends Vue {
const updatedReport = data.updateReportStatus;
report.status = updatedReport.status;
store.writeQuery({ query: REPORT, data: { report } });
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
},
});
} catch (error) {

View File

@ -593,12 +593,12 @@ defmodule Mobilizon.Events do
@spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
[Participant.t()]
def list_participants_for_event(
uuid,
id,
roles \\ @default_participant_roles,
page \\ nil,
limit \\ nil
) do
uuid
id
|> list_participants_for_event_query()
|> filter_role(roles)
|> Page.paginate(page, limit)
@ -688,7 +688,7 @@ defmodule Mobilizon.Events do
Returns the list of participations for an actor.
"""
@spec list_event_participations_for_actor(Actor.t(), integer | nil, integer | nil) ::
[Event.t()]
[Participant.t()]
def list_event_participations_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> event_participations_for_actor_query()
@ -1241,13 +1241,11 @@ defmodule Mobilizon.Events do
@spec event_participations_for_actor_query(integer) :: Ecto.Query.t()
def event_participations_for_actor_query(actor_id) do
from(
e in Event,
join: p in Participant,
join: a in Actor,
on: p.actor_id == a.id,
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: a.id == ^actor_id and p.role != ^:not_approved,
preload: [:picture, :tags]
where: p.actor_id == ^actor_id and p.role != ^:not_approved,
preload: [:event]
)
end
@ -1281,12 +1279,12 @@ defmodule Mobilizon.Events do
end
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
defp list_participants_for_event_query(event_uuid) do
defp list_participants_for_event_query(event_id) do
from(
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: e.uuid == ^event_uuid,
where: e.id == ^event_id,
preload: [:actor]
)
end

View File

@ -38,34 +38,42 @@ defmodule MobilizonWeb.Resolvers.Event do
end
end
@doc """
List participant for event (separate request)
"""
def list_participants_for_event(_parent, %{uuid: uuid, page: page, limit: limit}, _resolution) do
{:ok, Mobilizon.Events.list_participants_for_event(uuid, [], page, limit)}
end
@doc """
List participants for event (through an event request)
"""
def list_participants_for_event(
%Event{uuid: uuid},
%{page: page, limit: limit, roles: roles},
_resolution
%Event{id: event_id},
%{page: page, limit: limit, roles: roles, actor_id: actor_id},
%{context: %{current_user: %User{} = user}} = _resolution
) do
roles =
case roles do
"" ->
[]
with {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id),
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission, Mobilizon.Events.moderator_for_event?(event_id, actor_id)} do
roles =
case roles do
"" ->
[]
roles ->
roles
|> String.split(",")
|> Enum.map(&String.downcase/1)
|> Enum.map(&String.to_existing_atom/1)
end
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)}
{:ok, Mobilizon.Events.list_participants_for_event(event_id, roles, page, limit)}
else
{:is_owned, nil} ->
{: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"}
end
end
def list_participants_for_event(_, _args, _resolution) do
{:ok, []}
end
def stats_participants_for_event(%Event{id: id}, _args, _resolution) do

View File

@ -6,6 +6,7 @@ defmodule MobilizonWeb.Resolvers.Person do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.Participant
alias Mobilizon.Service.ActivityPub
alias Mobilizon.Users
alias Mobilizon.Users.User
@ -173,27 +174,33 @@ defmodule MobilizonWeb.Resolvers.Person do
end
@doc """
Returns the list of events this person is going to
Returns the participation for a specific event
"""
def person_going_to_events(%Actor{id: actor_id}, _args, %{context: %{current_user: user}}) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
events <- Events.list_event_participations_for_actor(actor) do
{:ok, events}
def person_participations(%Actor{id: actor_id}, %{event_id: event_id}, %{
context: %{current_user: user}
}) do
with {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id),
{:no_participant, {:ok, %Participant{} = participant}} <-
{:no_participant, Events.get_participant(event_id, actor_id)} do
{:ok, [participant]}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:no_participant, _} ->
{:ok, []}
end
end
@doc """
Returns the list of events this person is going to
"""
def person_going_to_events(_parent, %{}, %{context: %{current_user: user}}) do
with %Actor{} = actor <- Users.get_actor_for_user(user),
events <- Events.list_event_participations_for_actor(actor) do
{:ok, events}
def person_participations(%Actor{id: actor_id}, _args, %{context: %{current_user: user}}) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
participations <- Events.list_event_participations_for_actor(actor) do
{:ok, participations}
else
{:is_owned, false} ->
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
end
end

View File

@ -225,10 +225,11 @@ defmodule MobilizonWeb.Resolvers.User do
@doc """
Returns the list of events for all of this user's identities are going to
"""
def user_participations(_parent, args, %{
context: %{current_user: %User{id: user_id}}
def user_participations(%User{id: user_id}, args, %{
context: %{current_user: %User{id: logged_user_id}}
}) do
with participations <-
with true <- user_id == logged_user_id,
participations <-
Events.list_participations_for_user(
user_id,
Map.get(args, :after_datetime),

View File

@ -116,7 +116,6 @@ defmodule MobilizonWeb.Schema do
import_fields(:person_queries)
import_fields(:group_queries)
import_fields(:event_queries)
import_fields(:participant_queries)
import_fields(:tag_queries)
import_fields(:address_queries)
import_fields(:config_queries)

View File

@ -56,8 +56,11 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
)
@desc "The list of events this person goes to"
field :going_to_events, list_of(:event) do
resolve(&Person.person_going_to_events/3)
field(:participations, list_of(:participant),
description: "The list of events this person goes to"
) do
arg(:event_id, :id)
resolve(&Person.person_participations/3)
end
end

View File

@ -61,6 +61,7 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
arg(:roles, :string, default_value: "")
arg(:actor_id, :id)
resolve(&Event.list_participants_for_event/3)
end

View File

@ -44,16 +44,6 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
field(:actor, :deleted_object)
end
object :participant_queries do
@desc "Get all participants for an event uuid"
field :participants, list_of(:participant) do
arg(:uuid, non_null(:uuid))
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Resolvers.Event.list_participants_for_event/3)
end
end
object :participant_mutations do
@desc "Join an event"
field :join_event, :participant do

View File

@ -47,7 +47,7 @@ defmodule MobilizonWeb.Schema.UserType do
field(:role, :user_role, description: "The role for the user")
field(:participations, list_of(:participant),
description: "The list of events this person goes to"
description: "The list of events this user goes to"
) do
arg(:after_datetime, :datetime)
arg(:before_datetime, :datetime)

View File

@ -128,14 +128,18 @@ defmodule Mobilizon.Service.Export.Feed do
%FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do
case actor do
%Actor{} = actor ->
events = fetch_identity_going_to_events(actor)
events = actor |> fetch_identity_participations() |> participations_to_events()
{:ok, build_actor_feed(actor, events, false)}
nil ->
with actors <- Users.get_actors_for_user(user),
events <-
actors
|> Enum.map(&Events.list_event_participations_for_actor/1)
|> Enum.map(fn actor ->
actor
|> Events.list_event_participations_for_actor()
|> participations_to_events()
end)
|> Enum.concat() do
{:ok, build_user_feed(events, user, token)}
end
@ -143,12 +147,18 @@ defmodule Mobilizon.Service.Export.Feed do
end
end
defp fetch_identity_going_to_events(%Actor{} = actor) do
defp fetch_identity_participations(%Actor{} = actor) do
with events <- Events.list_event_participations_for_actor(actor) do
events
end
end
defp participations_to_events(participations) do
participations
|> Enum.map(& &1.event_id)
|> Enum.map(&Events.get_event_with_preload!/1)
end
# Build an atom feed from actor and it's public events
@spec build_user_feed(list(), User.t(), String.t()) :: String.t()
defp build_user_feed(events, %User{email: email}, token) do

View File

@ -33,7 +33,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
dtend: event.ends_on,
description: event.description,
uid: event.uuid,
categories: [event.category] ++ (event.tags |> Enum.map(& &1.slug))
categories: event.tags |> Enum.map(& &1.slug)
}
end
@ -52,7 +52,8 @@ defmodule Mobilizon.Service.Export.ICalendar do
@spec export_private_actor(Actor.t()) :: String.t()
def export_private_actor(%Actor{} = actor) do
with events <- Events.list_event_participations_for_actor(actor) do
with events <-
actor |> Events.list_event_participations_for_actor() |> participations_to_events() do
{:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()}
end
end
@ -107,7 +108,11 @@ defmodule Mobilizon.Service.Export.ICalendar do
with actors <- Users.get_actors_for_user(user),
events <-
actors
|> Enum.map(&Events.list_event_participations_for_actor/1)
|> Enum.map(fn actor ->
actor
|> Events.list_event_participations_for_actor()
|> participations_to_events()
end)
|> Enum.concat() do
{:ok,
%ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()}
@ -115,4 +120,10 @@ defmodule Mobilizon.Service.Export.ICalendar do
end
end
end
defp participations_to_events(participations) do
participations
|> Enum.map(& &1.event_id)
|> Enum.map(&Events.get_event_with_preload!/1)
end
end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api
# timestamp: Tue Sep 24 2019 18:20:05 GMT+0200 (GMT+02:00)
# timestamp: Wed Sep 25 2019 16:41:05 GMT+0200 (GMT+02:00)
schema {
query: RootQueryType
@ -287,7 +287,7 @@ type Event implements ActionLogObject {
participantStats: ParticipantStats
"""The event's participants"""
participants(limit: Int = 10, page: Int = 1, roles: String = ""): [Participant]
participants(actorId: ID, limit: Int = 10, page: Int = 1, roles: String = ""): [Participant]
"""Phone address for the event"""
phoneAddress: String
@ -710,9 +710,6 @@ type Person implements Actor {
"""Number of actors following this actor"""
followingCount: Int
"""The list of events this person goes to"""
goingToEvents: [Event]
"""Internal ID for this person"""
id: ID
@ -734,6 +731,9 @@ type Person implements Actor {
"""A list of the events this actor has organized"""
organizedEvents: [Event]
"""The list of events this person goes to"""
participations(eventId: ID): [Participant]
"""The actor's preferred username"""
preferredUsername: String
@ -1128,9 +1128,6 @@ type RootQueryType {
"""Get the current user"""
loggedUser: User
"""Get all participants for an event uuid"""
participants(limit: Int = 10, page: Int = 1, uuid: UUID!): [Participant]
"""Get a person by it's preferred username"""
person(preferredUsername: String!): Person
@ -1223,7 +1220,7 @@ type User {
"""The user's ID"""
id: ID!
"""The list of events this person goes to"""
"""The list of events this user goes to"""
participations(afterDatetime: DateTime, beforeDatetime: DateTime, limit: Int = 10, page: Int = 1): [Participant]
"""The user's list of profiles (identities)"""

View File

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

View File

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

View File

@ -106,8 +106,8 @@ defmodule MobilizonWeb.FeedControllerTest do
assert entry.summary in [event1.title, event2.title]
end)
assert entry1.categories == [event1.category, tag1.slug]
assert entry2.categories == [event2.category, tag1.slug, tag2.slug]
assert entry1.categories == [tag1.slug]
assert entry2.categories == [tag1.slug, tag2.slug]
end
test "it returns a 404 page for the actor's public events iCal feed with an actor not publicly visible",
@ -174,7 +174,7 @@ defmodule MobilizonWeb.FeedControllerTest do
assert entry1.summary == event1.title
assert entry1.categories == [event1.category, tag1.slug, tag2.slug]
assert entry1.categories == [tag1.slug, tag2.slug]
end
end
@ -311,6 +311,7 @@ defmodule MobilizonWeb.FeedControllerTest do
[entry1] = ExIcal.parse(conn.resp_body)
assert entry1.summary == event1.title
assert entry1.categories == event1.tags |> Enum.map(& &1.slug)
end
test "it returns 404 for an not existing feed", %{conn: conn} do

View File

@ -129,13 +129,15 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
actor: actor
} do
event = insert(:event, %{organizer_actor: actor})
participant = insert(:participant, %{actor: actor, event: event})
participant2 = insert(:participant, %{event: event})
insert(:participant, %{actor: actor, event: event, role: :creator})
user2 = insert(:user)
actor2 = insert(:actor, user: user2)
participant2 = insert(:participant, %{event: event, actor: actor2, role: :participant})
mutation = """
mutation {
leaveEvent(
actor_id: #{participant.actor.id},
actor_id: #{participant2.actor.id},
event_id: #{event.id}
) {
actor {
@ -150,40 +152,64 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
res =
conn
|> auth_conn(user)
|> auth_conn(user2)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["leaveEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] ==
to_string(participant.actor.id)
to_string(participant2.actor.id)
query = """
{
event(uuid: "#{event.uuid}") {
participants {
role,
actor {
preferredUsername
person(preferredUsername: "#{actor.preferred_username}") {
participations(eventId: "#{event.id}") {
event {
uuid,
title
},
role
}
}
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["event"]["participants"] == [
assert json_response(res, 200)["data"]["person"]["participations"] == [
%{
"actor" => %{
"preferredUsername" => participant2.actor.preferred_username
"event" => %{
"uuid" => event.uuid,
"title" => event.title
},
"role" => "CREATOR"
}
]
query = """
{
person(preferredUsername: "#{actor2.preferred_username}") {
participations(eventId: "#{event.id}") {
event {
uuid,
title
},
role
}
}
}
"""
res =
conn
|> auth_conn(user2)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["participations"] == []
end
test "actor_leave_event/3 should check if the participant is the only creator", %{
@ -324,17 +350,23 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert hd(json_response(res, 200)["errors"])["message"] =~ "Participant not found"
end
test "list_participants_for_event/3 returns participants for an event", context do
test "list_participants_for_event/3 returns participants for an event", %{
conn: conn,
actor: actor,
user: user
} do
event =
@event
|> Map.put(:organizer_actor_id, context.actor.id)
|> Map.put(:organizer_actor_id, actor.id)
{:ok, event} = Events.create_event(event)
query = """
{
event(uuid: "#{event.uuid}") {
participants(roles: "participant,moderator,administrator,creator") {
participants(roles: "participant,moderator,administrator,creator", actor_id: "#{
actor.id
}") {
role,
actor {
preferredUsername
@ -345,13 +377,16 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
"""
res =
context.conn
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["event"]["participants"] == [
%{
"actor" => %{
"preferredUsername" => context.actor.preferred_username
"preferredUsername" => actor.preferred_username
},
"role" => "CREATOR"
}
@ -368,7 +403,9 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
query = """
{
event(uuid: "#{event.uuid}") {
participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator") {
participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{
actor.id
}") {
role,
actor {
preferredUsername
@ -379,7 +416,8 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
"""
res =
context.conn
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants =
@ -402,7 +440,9 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
query = """
{
event(uuid: "#{event.uuid}") {
participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator") {
participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{
actor.id
}") {
role,
actor {
preferredUsername
@ -413,7 +453,8 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
"""
res =
context.conn
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants =
@ -427,7 +468,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert sorted_participants == [
%{
"actor" => %{
"preferredUsername" => context.actor.preferred_username
"preferredUsername" => actor.preferred_username
},
"role" => "CREATOR"
}

View File

@ -473,7 +473,9 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
assert hd(json_response(res, 200)["errors"])["message"] == "Person with name riri not found"
end
end
describe "get_current_person/3" do
test "get_current_person/3 can return the events the person is going to", context do
user = insert(:user)
actor = insert(:actor, user: user)
@ -481,9 +483,11 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
query = """
{
loggedPerson {
goingToEvents {
uuid,
title
participations {
event {
uuid,
title
}
}
}
}
@ -494,7 +498,7 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "logged_person"))
assert json_response(res, 200)["data"]["loggedPerson"]["goingToEvents"] == []
assert json_response(res, 200)["data"]["loggedPerson"]["participations"] == []
event = insert(:event, %{organizer_actor: actor})
insert(:participant, %{actor: actor, event: event})
@ -504,8 +508,8 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "logged_person"))
assert json_response(res, 200)["data"]["loggedPerson"]["goingToEvents"] == [
%{"title" => event.title, "uuid" => event.uuid}
assert json_response(res, 200)["data"]["loggedPerson"]["participations"] == [
%{"event" => %{"title" => event.title, "uuid" => event.uuid}}
]
end
@ -519,9 +523,11 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
query = """
{
person(preferredUsername: "#{actor.preferred_username}") {
goingToEvents {
uuid,
title
participations {
event {
uuid,
title
}
}
}
}
@ -532,14 +538,16 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["goingToEvents"] == []
assert json_response(res, 200)["data"]["person"]["participations"] == []
query = """
{
person(preferredUsername: "#{actor_from_other_user.preferred_username}") {
goingToEvents {
uuid,
title
participations {
event {
uuid,
title
}
}
}
}
@ -550,10 +558,45 @@ defmodule MobilizonWeb.Resolvers.PersonResolverTest do
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["goingToEvents"] == nil
assert json_response(res, 200)["data"]["person"]["participations"] == nil
assert hd(json_response(res, 200)["errors"])["message"] ==
"Actor id is not owned by authenticated user"
end
test "find_person/3 can return the participation for an identity on a specific event",
context do
user = insert(:user)
actor = insert(:actor, user: user)
event = insert(:event, organizer_actor: actor)
insert(:participant, event: event, actor: actor)
query = """
{
person(preferredUsername: "#{actor.preferred_username}") {
participations(eventId: "#{event.id}") {
event {
uuid,
title
}
}
}
}
"""
res =
context.conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["participations"] == [
%{
"event" => %{
"uuid" => event.uuid,
"title" => event.title
}
}
]
end
end
end