Export participants to different formats
* CSV * PDF (requires Python dependency `weasyprint`) * ODS (requires Python dependency `pyexcel_ods3`) Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
5dd24e1c9e
commit
0c667b13ae
2
.gitignore
vendored
2
.gitignore
vendored
@ -27,6 +27,7 @@ priv/data/*
|
||||
priv/errors/*
|
||||
!priv/errors/.gitkeep
|
||||
priv/cert/
|
||||
priv/python/__pycache__/
|
||||
.vscode/
|
||||
cover/
|
||||
site/
|
||||
@ -37,6 +38,7 @@ test/uploads/
|
||||
uploads/*
|
||||
release/
|
||||
!uploads/.gitkeep
|
||||
!uploads/exports/.gitkeep
|
||||
.idea
|
||||
*.mo
|
||||
*.po~
|
||||
|
@ -28,6 +28,7 @@ variables:
|
||||
# Release elements
|
||||
PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${CI_PROJECT_NAME}"
|
||||
ARCH: "amd64"
|
||||
EXPORT_FORMATS: "csv,ods,pdf"
|
||||
|
||||
cache:
|
||||
key: "${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
|
||||
|
@ -8,4 +8,5 @@
|
||||
73B351E4CB3AF715AD450A085F5E6304
|
||||
BBACD7F0BACD4A6D3010C26604671692
|
||||
6D4D4A4821B93BCFAC9CDBB367B34C4B
|
||||
5674F0D127852889ED0132DC2F442AAB
|
||||
5674F0D127852889ED0132DC2F442AAB
|
||||
1600B7206E47F630D94AB54C360906F0
|
@ -285,6 +285,7 @@ config :mobilizon, Oban,
|
||||
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.SendActivityRecapWorker, queue: :notifications},
|
||||
{"@daily", Mobilizon.Service.Workers.CleanOldActivityWorker, queue: :background}
|
||||
]},
|
||||
@ -320,6 +321,11 @@ config :mobilizon, Mobilizon.Service.Notifier.Email, enabled: true
|
||||
|
||||
config :mobilizon, Mobilizon.Service.Notifier.Push, enabled: true
|
||||
|
||||
config :mobilizon, :exports,
|
||||
formats: [
|
||||
Mobilizon.Service.Export.Participants.CSV
|
||||
]
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
||||
|
@ -1,10 +1,15 @@
|
||||
FROM elixir:latest
|
||||
LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
|
||||
|
||||
ENV REFRESHED_AT=2021-06-07
|
||||
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool
|
||||
ENV REFRESHED_AT=2021-10-04
|
||||
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool python3-pip python3-setuptools
|
||||
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
|
||||
RUN npm install -g yarn wait-on
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
RUN mix local.hex --force && mix local.rebar --force
|
||||
# Weasyprint 53 requires pango >= 1.44.0, which is not available in Stretch.
|
||||
# TODO: Remove the version requirement when elixir:latest is based on Bullseye
|
||||
# https://github.com/erlang/docker-erlang-otp/issues/362
|
||||
# https://github.com/Kozea/WeasyPrint/issues/1384
|
||||
RUN pip3 install -Iv weasyprint==52 pyexcel_ods3
|
||||
RUN curl https://dbip.mirror.framasoft.org/files/dbip-city-lite-latest.mmdb --output GeoLite2-City.mmdb -s && mkdir -p /usr/share/GeoIP && mv GeoLite2-City.mmdb /usr/share/GeoIP/
|
||||
|
@ -175,3 +175,13 @@ export const WEB_PUSH = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const EVENT_PARTICIPANTS = gql`
|
||||
query EventParticipants {
|
||||
config {
|
||||
exportFormats {
|
||||
eventParticipants
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -574,3 +574,13 @@ export const CLOSE_EVENTS = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const EXPORT_EVENT_PARTICIPATIONS = gql`
|
||||
mutation ExportEventParticipants(
|
||||
$eventId: ID!
|
||||
$format: ExportFormatEnum
|
||||
$roles: [ParticipantRoleEnum]
|
||||
) {
|
||||
exportEventParticipants(eventId: $eventId, format: $format, roles: $roles)
|
||||
}
|
||||
`;
|
||||
|
@ -102,4 +102,7 @@ export interface IConfig {
|
||||
enabled: boolean;
|
||||
publicKey: string;
|
||||
};
|
||||
exportFormats: {
|
||||
eventParticipants: string[];
|
||||
};
|
||||
}
|
||||
|
@ -1,235 +1,255 @@
|
||||
<template>
|
||||
<main class="container">
|
||||
<section v-if="event">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_EVENTS }">{{
|
||||
$t("My events")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
>{{ event.title }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.PARTICIPANTS,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
>{{ $t("Participants") }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<h2 class="title">{{ $t("Participants") }}</h2>
|
||||
<b-field :label="$t('Status')" horizontal>
|
||||
<b-select v-model="role">
|
||||
<option :value="null">
|
||||
{{ $t("Everything") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.CREATOR">
|
||||
{{ $t("Organizer") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.PARTICIPANT">
|
||||
{{ $t("Participant") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.NOT_APPROVED">
|
||||
{{ $t("Not approved") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.REJECTED">
|
||||
{{ $t("Rejected") }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-table
|
||||
:data="event.participants.elements"
|
||||
ref="queueTable"
|
||||
detailed
|
||||
detail-key="id"
|
||||
:checked-rows.sync="checkedRows"
|
||||
checkable
|
||||
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
|
||||
checkbox-position="left"
|
||||
:show-detail-icon="false"
|
||||
:loading="this.$apollo.loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
:pagination-simple="true"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="event.participants.total"
|
||||
:per-page="PARTICIPANTS_PER_PAGE"
|
||||
backend-sorting
|
||||
:default-sort-direction="'desc'"
|
||||
:default-sort="['insertedAt', 'desc']"
|
||||
@page-change="(newPage) => (page = newPage)"
|
||||
@sort="(field, order) => $emit('sort', field, order)"
|
||||
>
|
||||
<b-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="$t('Participant')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="media">
|
||||
<figure
|
||||
class="media-left image is-48x48"
|
||||
v-if="props.row.actor.avatar"
|
||||
>
|
||||
<img
|
||||
class="is-rounded"
|
||||
:src="props.row.actor.avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
</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-dark"
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t("Anonymous participant") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</b-table-column>
|
||||
<b-table-column field="role" :label="$t('Role')" v-slot="props">
|
||||
<b-tag
|
||||
type="is-primary"
|
||||
v-if="props.row.role === ParticipantRole.CREATOR"
|
||||
>
|
||||
{{ $t("Organizer") }}
|
||||
</b-tag>
|
||||
<b-tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
|
||||
{{ $t("Participant") }}
|
||||
</b-tag>
|
||||
<b-tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
|
||||
{{ $t("Not confirmed") }}
|
||||
</b-tag>
|
||||
<b-tag
|
||||
type="is-warning"
|
||||
v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
|
||||
>
|
||||
{{ $t("Not approved") }}
|
||||
</b-tag>
|
||||
<b-tag
|
||||
type="is-danger"
|
||||
v-else-if="props.row.role === ParticipantRole.REJECTED"
|
||||
>
|
||||
{{ $t("Rejected") }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
<b-table-column
|
||||
field="metadata.message"
|
||||
class="column-message"
|
||||
:label="$t('Message')"
|
||||
v-slot="props"
|
||||
>
|
||||
<div
|
||||
@click="toggleQueueDetails(props.row)"
|
||||
:class="{
|
||||
'ellipsed-message':
|
||||
props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
|
||||
<section class="section container" v-if="event">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_EVENTS }">{{
|
||||
$t("My events")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
v-if="props.row.metadata && props.row.metadata.message"
|
||||
>{{ event.title }}</router-link
|
||||
>
|
||||
<p>
|
||||
{{ props.row.metadata.message | ellipsize }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text"
|
||||
v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH"
|
||||
@click.stop="toggleQueueDetails(props.row)"
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.PARTICIPANTS,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
>{{ $t("Participants") }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<h1 class="title">{{ $t("Participants") }}</h1>
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<b-field :label="$t('Status')" horizontal label-for="role-select">
|
||||
<b-select v-model="role" id="role-select">
|
||||
<option :value="null">
|
||||
{{ $t("Everything") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.CREATOR">
|
||||
{{ $t("Organizer") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.PARTICIPANT">
|
||||
{{ $t("Participant") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.NOT_APPROVED">
|
||||
{{ $t("Not approved") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.REJECTED">
|
||||
{{ $t("Rejected") }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="level-item" v-if="exportFormats.length > 0">
|
||||
<b-dropdown aria-role="list">
|
||||
<template #trigger="{ active }">
|
||||
<b-button
|
||||
:label="$t('Export')"
|
||||
type="is-primary"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item
|
||||
v-for="format in exportFormats"
|
||||
:key="format"
|
||||
@click="exportParticipants(format)"
|
||||
aria-role="listitem"
|
||||
>{{ format }}</b-dropdown-item
|
||||
>
|
||||
{{
|
||||
openDetailedRows[props.row.id]
|
||||
? $t("View less")
|
||||
: $t("View more")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="has-text-grey-dark">
|
||||
{{ $t("No message") }}
|
||||
</p>
|
||||
</b-table-column>
|
||||
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ props.row.insertedAt | formatDateString }}<br />{{
|
||||
props.row.insertedAt | formatTimeString
|
||||
}}
|
||||
</span>
|
||||
</b-table-column>
|
||||
<template #detail="props">
|
||||
<article v-html="nl2br(props.row.metadata.message)" />
|
||||
</template>
|
||||
<template slot="empty">
|
||||
<section class="section">
|
||||
<div class="content has-text-grey-dark has-text-centered">
|
||||
<p>{{ $t("No participant matches the filters") }}</p>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<b-table
|
||||
:data="event.participants.elements"
|
||||
ref="queueTable"
|
||||
detailed
|
||||
detail-key="id"
|
||||
:checked-rows.sync="checkedRows"
|
||||
checkable
|
||||
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
|
||||
checkbox-position="left"
|
||||
:show-detail-icon="false"
|
||||
:loading="this.$apollo.loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
:pagination-simple="true"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="event.participants.total"
|
||||
:per-page="PARTICIPANTS_PER_PAGE"
|
||||
backend-sorting
|
||||
:default-sort-direction="'desc'"
|
||||
:default-sort="['insertedAt', 'desc']"
|
||||
@page-change="(newPage) => (page = newPage)"
|
||||
@sort="(field, order) => $emit('sort', field, order)"
|
||||
>
|
||||
<b-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="$t('Participant')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="media">
|
||||
<figure
|
||||
class="media-left image is-48x48"
|
||||
v-if="props.row.actor.avatar"
|
||||
>
|
||||
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
|
||||
</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-dark"
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t("Anonymous participant") }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template slot="bottom-left">
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
@click="acceptParticipants(checkedRows)"
|
||||
type="is-success"
|
||||
:disabled="!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"
|
||||
:disabled="!canRefuseParticipants"
|
||||
>
|
||||
{{
|
||||
$tc(
|
||||
"No participant to reject|Reject participant|Reject {number} participants",
|
||||
checkedRows.length,
|
||||
{ number: checkedRows.length }
|
||||
)
|
||||
}}
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
</b-table>
|
||||
</section>
|
||||
</main>
|
||||
</article>
|
||||
</b-table-column>
|
||||
<b-table-column field="role" :label="$t('Role')" v-slot="props">
|
||||
<b-tag
|
||||
type="is-primary"
|
||||
v-if="props.row.role === ParticipantRole.CREATOR"
|
||||
>
|
||||
{{ $t("Organizer") }}
|
||||
</b-tag>
|
||||
<b-tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
|
||||
{{ $t("Participant") }}
|
||||
</b-tag>
|
||||
<b-tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
|
||||
{{ $t("Not confirmed") }}
|
||||
</b-tag>
|
||||
<b-tag
|
||||
type="is-warning"
|
||||
v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
|
||||
>
|
||||
{{ $t("Not approved") }}
|
||||
</b-tag>
|
||||
<b-tag
|
||||
type="is-danger"
|
||||
v-else-if="props.row.role === ParticipantRole.REJECTED"
|
||||
>
|
||||
{{ $t("Rejected") }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
<b-table-column
|
||||
field="metadata.message"
|
||||
class="column-message"
|
||||
:label="$t('Message')"
|
||||
v-slot="props"
|
||||
>
|
||||
<div
|
||||
@click="toggleQueueDetails(props.row)"
|
||||
:class="{
|
||||
'ellipsed-message':
|
||||
props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
|
||||
}"
|
||||
v-if="props.row.metadata && props.row.metadata.message"
|
||||
>
|
||||
<p v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH">
|
||||
{{ props.row.metadata.message | ellipsize }}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ props.row.metadata.message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text"
|
||||
v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH"
|
||||
@click.stop="toggleQueueDetails(props.row)"
|
||||
>
|
||||
{{
|
||||
openDetailedRows[props.row.id] ? $t("View less") : $t("View more")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="has-text-grey-dark">
|
||||
{{ $t("No message") }}
|
||||
</p>
|
||||
</b-table-column>
|
||||
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ props.row.insertedAt | formatDateString }}<br />{{
|
||||
props.row.insertedAt | formatTimeString
|
||||
}}
|
||||
</span>
|
||||
</b-table-column>
|
||||
<template #detail="props">
|
||||
<article v-html="nl2br(props.row.metadata.message)" />
|
||||
</template>
|
||||
<template slot="empty">
|
||||
<section class="section">
|
||||
<div class="content has-text-grey-dark has-text-centered">
|
||||
<p>{{ $t("No participant matches the filters") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template slot="bottom-left">
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
@click="acceptParticipants(checkedRows)"
|
||||
type="is-success"
|
||||
:disabled="!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"
|
||||
:disabled="!canRefuseParticipants"
|
||||
>
|
||||
{{
|
||||
$tc(
|
||||
"No participant to reject|Reject participant|Reject {number} participants",
|
||||
checkedRows.length,
|
||||
{ number: checkedRows.length }
|
||||
)
|
||||
}}
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
</b-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@ -237,10 +257,14 @@ import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator";
|
||||
import { ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "../../types/participant.model";
|
||||
import { IEvent, IEventParticipantStats } from "../../types/event.model";
|
||||
import { PARTICIPANTS, UPDATE_PARTICIPANT } from "../../graphql/event";
|
||||
import {
|
||||
EXPORT_EVENT_PARTICIPATIONS,
|
||||
PARTICIPANTS,
|
||||
UPDATE_PARTICIPANT,
|
||||
} from "../../graphql/event";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
import { IPerson, usernameWithDomain } from "../../types/actor";
|
||||
import { CONFIG } from "../../graphql/config";
|
||||
import { EVENT_PARTICIPANTS } from "../../graphql/config";
|
||||
import { IConfig } from "../../types/config.model";
|
||||
import { nl2br } from "../../utils/html";
|
||||
import { asyncForEach } from "../../utils/asyncForEach";
|
||||
@ -256,7 +280,7 @@ const MESSAGE_ELLIPSIS_LENGTH = 130;
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
config: CONFIG,
|
||||
config: EVENT_PARTICIPANTS,
|
||||
event: {
|
||||
query: PARTICIPANTS,
|
||||
variables() {
|
||||
@ -390,6 +414,46 @@ export default class Participants extends Vue {
|
||||
this.checkedRows = [];
|
||||
}
|
||||
|
||||
async exportParticipants(type: "CSV" | "PDF" | "ODS"): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
data: { exportEventParticipants },
|
||||
} = await this.$apollo.mutate({
|
||||
mutation: EXPORT_EVENT_PARTICIPATIONS,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
format: type,
|
||||
},
|
||||
});
|
||||
const link =
|
||||
window.origin +
|
||||
"/exports/" +
|
||||
type.toLowerCase() +
|
||||
"/" +
|
||||
exportEventParticipants;
|
||||
console.log(link);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.href = link;
|
||||
a.setAttribute("download", "true");
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
|
||||
this.$notifier.error(e.graphQLErrors[0].message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get exportFormats(): string[] {
|
||||
return (this.config?.exportFormats?.eventParticipants || []).map((key) =>
|
||||
key.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* We can accept participants if at least one of them is not approved
|
||||
*/
|
||||
@ -449,8 +513,9 @@ export default class Participants extends Vue {
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss" scoped>
|
||||
section {
|
||||
padding: 1rem 0;
|
||||
section.container.container {
|
||||
padding: 1rem;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.table {
|
||||
|
@ -26,7 +26,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
|
||||
accept_join_entities | accept_follow_entities | accept_invite_entities
|
||||
|
||||
@spec accept(acceptable_types, acceptable_entities, boolean, map) ::
|
||||
{:ok, ActivityStream.t(), acceptable_entities}
|
||||
{:ok, ActivityStream.t(), acceptable_entities} | {:error, Ecto.Changeset.t()}
|
||||
def accept(type, entity, local \\ true, additional \\ %{}) do
|
||||
Logger.debug("We're accepting something")
|
||||
|
||||
|
@ -31,7 +31,8 @@ defmodule Mobilizon.GraphQL.API.Participations do
|
||||
@doc """
|
||||
Update participation status
|
||||
"""
|
||||
@spec update(Participant.t(), Actor.t(), atom()) :: {:ok, Activity.t(), Participant.t()}
|
||||
@spec update(Participant.t(), Actor.t(), atom()) ::
|
||||
{:ok, Activity.t(), Participant.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update(%Participant{} = participation, %Actor{} = moderator, :participant),
|
||||
do: accept(participation, moderator)
|
||||
|
||||
@ -46,7 +47,8 @@ defmodule Mobilizon.GraphQL.API.Participations do
|
||||
def update(%Participant{} = participation, %Actor{} = moderator, :rejected),
|
||||
do: reject(participation, moderator)
|
||||
|
||||
@spec accept(Participant.t(), Actor.t()) :: {:ok, Activity.t(), Participant.t()}
|
||||
@spec accept(Participant.t(), Actor.t()) ::
|
||||
{:ok, Activity.t(), Participant.t()} | {:error, Ecto.Changeset.t()}
|
||||
defp accept(
|
||||
%Participant{} = participation,
|
||||
%Actor{} = moderator
|
||||
|
@ -153,7 +153,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
|
||||
enabled: !is_nil(Application.get_env(:web_push_encryption, :vapid_details)),
|
||||
public_key:
|
||||
get_in(Application.get_env(:web_push_encryption, :vapid_details), [:public_key])
|
||||
}
|
||||
},
|
||||
export_formats: Config.instance_export_formats()
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event.Utils do
|
||||
alias Mobilizon.Federation.ActivityPub.Permission
|
||||
import Mobilizon.Service.Guards, only: [is_valid_string: 1]
|
||||
|
||||
@spec can_event_be_updated_by?(%Event{id: String.t()}, Actor.t()) ::
|
||||
@spec can_event_be_updated_by?(Event.t(), Actor.t()) ::
|
||||
boolean
|
||||
def can_event_be_updated_by?(
|
||||
%Event{attributed_to: %Actor{type: :Group}} = event,
|
||||
@ -24,7 +24,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event.Utils do
|
||||
Event.can_be_managed_by?(event, actor_member_id)
|
||||
end
|
||||
|
||||
@spec can_event_be_deleted_by?(%Event{id: String.t(), url: String.t()}, Actor.t()) ::
|
||||
@spec can_event_be_deleted_by?(Event.t(), Actor.t()) ::
|
||||
boolean
|
||||
def can_event_be_deleted_by?(
|
||||
%Event{attributed_to: %Actor{type: :Group}, id: event_id, url: event_url} = event,
|
||||
|
@ -6,6 +6,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.GraphQL.API.Participations
|
||||
alias Mobilizon.Service.Export.Participants.{CSV, ODS, PDF}
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Web.Email
|
||||
alias Mobilizon.Web.Email.Checker
|
||||
@ -225,7 +226,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
||||
end
|
||||
|
||||
@spec update_participation(any(), map(), Absinthe.Resolution.t()) ::
|
||||
{:ok, Participation.t()} | {:error, String.t()}
|
||||
{:ok, Participation.t()} | {:error, String.t() | Ecto.Changeset.t()}
|
||||
def update_participation(
|
||||
_parent,
|
||||
%{id: participation_id, role: new_role},
|
||||
@ -236,28 +237,29 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
||||
}
|
||||
) do
|
||||
# Check that participation already exists
|
||||
with {:has_participation, %Participant{role: old_role, event_id: event_id} = participation} <-
|
||||
{:has_participation, Events.get_participant(participation_id)},
|
||||
{:same_role, false} <- {:same_role, new_role == old_role},
|
||||
# Check that moderator has right
|
||||
{:event, %Event{} = event} <- {:event, Events.get_event_with_preload!(event_id)},
|
||||
{:event_can_be_managed, true} <-
|
||||
{:event_can_be_managed, can_event_be_updated_by?(event, moderator_actor)},
|
||||
{:ok, _activity, participation} <-
|
||||
Participations.update(participation, moderator_actor, new_role) do
|
||||
{:ok, participation}
|
||||
else
|
||||
{:has_participation, nil} ->
|
||||
{:error, dgettext("errors", "Participant not found")}
|
||||
|
||||
{:event_can_be_managed, _} ->
|
||||
{:error,
|
||||
dgettext("errors", "Provided profile doesn't have moderator permissions on this event")}
|
||||
case Events.get_participant(participation_id) do
|
||||
%Participant{role: old_role, event_id: event_id} = participation ->
|
||||
if new_role != old_role do
|
||||
%Event{} = event = Events.get_event_with_preload!(event_id)
|
||||
|
||||
{:same_role, true} ->
|
||||
{:error, dgettext("errors", "Participant already has role %{role}", role: new_role)}
|
||||
if can_event_be_updated_by?(event, moderator_actor) do
|
||||
with {:ok, _activity, participation} <-
|
||||
Participations.update(participation, moderator_actor, new_role) do
|
||||
{:ok, participation}
|
||||
end
|
||||
else
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"Provided profile doesn't have moderator permissions on this event"
|
||||
)}
|
||||
end
|
||||
else
|
||||
{:error, dgettext("errors", "Participant already has role %{role}", role: new_role)}
|
||||
end
|
||||
|
||||
{:error, :participant_not_found} ->
|
||||
nil ->
|
||||
{:error, dgettext("errors", "Participant not found")}
|
||||
end
|
||||
end
|
||||
@ -272,16 +274,71 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
||||
with {:has_participant,
|
||||
%Participant{actor: actor, role: :not_confirmed, event: event} = participant} <-
|
||||
{:has_participant, Events.get_participant_by_confirmation_token(confirmation_token)},
|
||||
default_role <- Events.get_default_participant_role(event),
|
||||
{:ok, _activity, %Participant{} = participant} <-
|
||||
Participations.update(participant, actor, default_role) do
|
||||
Participations.update(participant, actor, Events.get_default_participant_role(event)) do
|
||||
{:ok, participant}
|
||||
else
|
||||
{:has_participant, _} ->
|
||||
{:has_participant, nil} ->
|
||||
{:error, dgettext("errors", "This token is invalid")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = err} ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
@spec export_event_participants(any(), map(), Absinthe.Resolution.t()) :: {:ok, String.t()}
|
||||
def export_event_participants(_parent, %{event_id: event_id, roles: roles, format: format}, %{
|
||||
context: %{
|
||||
current_user: %User{locale: locale},
|
||||
current_actor: %Actor{} = moderator_actor
|
||||
}
|
||||
}) do
|
||||
case Events.get_event_with_preload(event_id) do
|
||||
{:ok, %Event{} = event} ->
|
||||
if can_event_be_updated_by?(event, moderator_actor) do
|
||||
case export_format(format, event, roles, locale) do
|
||||
{:ok, path} ->
|
||||
{:ok, path}
|
||||
|
||||
{:error, :export_dependency_not_installed} ->
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"A dependency needed to export to %{format} is not installed",
|
||||
format: format
|
||||
)}
|
||||
|
||||
{:error, :failed_to_save_upload} ->
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"An error occured while saving export",
|
||||
format: format
|
||||
)}
|
||||
|
||||
{:error, :format_not_supported} ->
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"Format not supported"
|
||||
)}
|
||||
end
|
||||
else
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"Provided profile doesn't have moderator permissions on this event"
|
||||
)}
|
||||
end
|
||||
|
||||
{:error, :event_not_found} ->
|
||||
{:error,
|
||||
dgettext("errors", "Event with this ID %{id} doesn't exist", id: inspect(event_id))}
|
||||
end
|
||||
end
|
||||
|
||||
def export_event_participants(_, _, _), do: {:error, :unauthorized}
|
||||
|
||||
@spec valid_email?(String.t() | nil) :: boolean
|
||||
defp valid_email?(email) when is_nil(email), do: false
|
||||
|
||||
@ -290,4 +347,24 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
||||
|> String.trim()
|
||||
|> Checker.valid?()
|
||||
end
|
||||
|
||||
@spec export_format(atom(), Event.t(), list(), String.t()) ::
|
||||
{:ok, String.t()}
|
||||
| {:error,
|
||||
:format_not_supported | :export_dependency_not_installed | :failed_to_save_upload}
|
||||
defp export_format(format, event, roles, locale) do
|
||||
case format do
|
||||
:csv ->
|
||||
CSV.export(event, roles: roles, locale: locale)
|
||||
|
||||
:pdf ->
|
||||
PDF.export(event, roles: roles, locale: locale)
|
||||
|
||||
:ods ->
|
||||
ODS.export(event, roles: roles, locale: locale)
|
||||
|
||||
_ ->
|
||||
{:error, :format_not_supported}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -65,6 +65,8 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
|
||||
field(:auth, :auth, description: "The instance auth methods")
|
||||
field(:instance_feeds, :instance_feeds, description: "The instance's feed settings")
|
||||
field(:web_push, :web_push, description: "Web Push settings for the instance")
|
||||
|
||||
field(:export_formats, :export_formats, description: "The instance list of export formats")
|
||||
end
|
||||
|
||||
@desc """
|
||||
@ -307,6 +309,15 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
|
||||
field(:public_key, :string, description: "The server's public WebPush VAPID key")
|
||||
end
|
||||
|
||||
@desc """
|
||||
Export formats configuration
|
||||
"""
|
||||
object :export_formats do
|
||||
field(:event_participants, list_of(:string),
|
||||
description: "The list of formats the event participants can be exported to"
|
||||
)
|
||||
end
|
||||
|
||||
object :config_queries do
|
||||
@desc "Get the instance config"
|
||||
field :config, :config do
|
||||
|
@ -70,6 +70,12 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
|
||||
value(:rejected, description: "The participant has been rejected from this event")
|
||||
end
|
||||
|
||||
enum :export_format_enum do
|
||||
value(:csv, description: "CSV format")
|
||||
value(:pdf, description: "PDF format")
|
||||
value(:ods, description: "ODS format")
|
||||
end
|
||||
|
||||
@desc "Represents a deleted participant"
|
||||
object :deleted_participant do
|
||||
field(:id, :id, description: "The participant ID")
|
||||
@ -111,5 +117,20 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
|
||||
arg(:confirmation_token, non_null(:string), description: "The participation token")
|
||||
resolve(&Participant.confirm_participation_from_token/3)
|
||||
end
|
||||
|
||||
@desc "Export the event participants as a file"
|
||||
field :export_event_participants, :string do
|
||||
arg(:event_id, non_null(:id),
|
||||
description: "The ID from the event for which to export participants"
|
||||
)
|
||||
|
||||
arg(:roles, list_of(:participant_role_enum),
|
||||
default_value: [],
|
||||
description: "The participant roles to include"
|
||||
)
|
||||
|
||||
arg(:format, :export_format_enum, description: "The format in which to return the file")
|
||||
resolve(&Participant.export_event_participants/3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -47,6 +47,7 @@ defmodule Mobilizon do
|
||||
# workers
|
||||
Guardian.DB.Token.SweeperServer,
|
||||
ActivityPub.Federator,
|
||||
Mobilizon.PythonWorker,
|
||||
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
|
||||
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
|
||||
cachex_spec(
|
||||
|
@ -6,6 +6,7 @@ defmodule Mobilizon.Config do
|
||||
alias Mobilizon.Actors
|
||||
alias Mobilizon.Service.GitStatus
|
||||
require Logger
|
||||
import Mobilizon.Service.Export.Participants.Common, only: [enabled_formats: 0]
|
||||
|
||||
@type mobilizon_config :: [
|
||||
name: String.t(),
|
||||
@ -302,6 +303,13 @@ defmodule Mobilizon.Config do
|
||||
def instance_event_creation_enabled?,
|
||||
do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:creation)
|
||||
|
||||
@spec instance_export_formats :: %{event_participants: list(String.t())}
|
||||
def instance_export_formats do
|
||||
%{
|
||||
event_participants: enabled_formats()
|
||||
}
|
||||
end
|
||||
|
||||
@spec anonymous_actor_id :: integer
|
||||
def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id)
|
||||
@spec relay_actor_id :: integer
|
||||
|
@ -796,7 +796,7 @@ defmodule Mobilizon.Events do
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_participant_by_confirmation_token(String.t()) :: Participant.t()
|
||||
@spec get_participant_by_confirmation_token(String.t()) :: Participant.t() | nil
|
||||
def get_participant_by_confirmation_token(confirmation_token) do
|
||||
Participant
|
||||
|> where([p], fragment("? ->>'confirmation_token' = ?", p.metadata, ^confirmation_token))
|
||||
@ -857,9 +857,8 @@ defmodule Mobilizon.Events do
|
||||
limit \\ nil
|
||||
) do
|
||||
id
|
||||
|> list_participants_for_event_query()
|
||||
|> filter_role(roles)
|
||||
|> order_by(asc: :role)
|
||||
|> participants_for_event_query(roles)
|
||||
|> preload([:actor, :event])
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@ -1604,11 +1603,8 @@ defmodule Mobilizon.Events do
|
||||
|
||||
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
|
||||
defp list_participants_for_event_query(event_id) do
|
||||
from(
|
||||
p in Participant,
|
||||
where: p.event_id == ^event_id,
|
||||
preload: [:actor, :event]
|
||||
)
|
||||
Participant
|
||||
|> where([p], p.event_id == ^event_id)
|
||||
end
|
||||
|
||||
@spec list_participant_actors_for_event_query(String.t()) :: Ecto.Query.t()
|
||||
@ -1621,6 +1617,21 @@ defmodule Mobilizon.Events do
|
||||
)
|
||||
end
|
||||
|
||||
@spec participants_for_event_query(String.t(), list(atom())) :: Ecto.Query.t()
|
||||
def participants_for_event_query(id, roles \\ []) do
|
||||
id
|
||||
|> list_participants_for_event_query()
|
||||
|> filter_role(roles)
|
||||
|> order_by(asc: :role)
|
||||
end
|
||||
|
||||
def participant_for_event_export_query(id, roles) do
|
||||
id
|
||||
|> participants_for_event_query(roles)
|
||||
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|
||||
|> select([p, a], {p, a})
|
||||
end
|
||||
|
||||
@doc """
|
||||
List emails for local users (including anonymous ones) participating to an event
|
||||
|
||||
|
57
lib/mobilizon/export.ex
Normal file
57
lib/mobilizon/export.ex
Normal file
@ -0,0 +1,57 @@
|
||||
defmodule Mobilizon.Export do
|
||||
@moduledoc """
|
||||
Manage exported files
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query, only: [where: 3]
|
||||
alias Mobilizon.Storage.Repo
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
file_path: String.t(),
|
||||
file_name: String.t() | nil,
|
||||
file_size: integer() | nil,
|
||||
type: String.t(),
|
||||
reference: String.t(),
|
||||
format: String.t()
|
||||
}
|
||||
|
||||
@required_attrs [:file_path, :type, :reference, :format]
|
||||
@optional_attrs [:file_size, :file_name]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
schema "exports" do
|
||||
field(:file_path, :string)
|
||||
field(:file_size, :integer)
|
||||
field(:file_name, :string)
|
||||
field(:type, :string)
|
||||
field(:reference, :string)
|
||||
field(:format, :string)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(export, attrs) do
|
||||
export
|
||||
|> cast(attrs, @attrs)
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
|
||||
@spec get_export(String.t(), String.t(), String.t()) :: t() | nil
|
||||
def get_export(file_path, type, format) do
|
||||
__MODULE__
|
||||
|> where([e], e.file_path == ^file_path and e.type == ^type and e.format == ^format)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@spec outdated(String.t(), String.t(), integer()) :: list(t())
|
||||
def outdated(type, format, expiration) do
|
||||
expiration_date = DateTime.add(DateTime.utc_now(), -expiration)
|
||||
|
||||
__MODULE__
|
||||
|> where([e], e.type == ^type and e.format == ^format and e.updated_at < ^expiration_date)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
@ -34,6 +34,7 @@ defmodule Mobilizon.Users.PushSubscription do
|
||||
|> unique_constraint([:digest, :user_id], name: :user_push_subscriptions_user_id_digest_index)
|
||||
end
|
||||
|
||||
@spec compute_digest(map()) :: String.t()
|
||||
defp compute_digest(attrs) do
|
||||
data =
|
||||
Jason.encode!(%{endpoint: attrs.endpoint, keys: %{auth: attrs.auth, p256dh: attrs.p256dh}})
|
||||
|
@ -129,7 +129,7 @@ defmodule Mobilizon.Users.User do
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec registration_changeset(t, map) :: Ecto.Changeset.t()
|
||||
@spec registration_changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def registration_changeset(%__MODULE__{} = user, attrs) do
|
||||
user
|
||||
|> changeset(attrs)
|
||||
@ -147,7 +147,7 @@ defmodule Mobilizon.Users.User do
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec auth_provider_changeset(t, map) :: Ecto.Changeset.t()
|
||||
@spec auth_provider_changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def auth_provider_changeset(%__MODULE__{} = user, attrs) do
|
||||
user
|
||||
|> changeset(attrs)
|
||||
@ -156,13 +156,13 @@ defmodule Mobilizon.Users.User do
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec send_password_reset_changeset(t, map) :: Ecto.Changeset.t()
|
||||
@spec send_password_reset_changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def send_password_reset_changeset(%__MODULE__{} = user, attrs) do
|
||||
cast(user, attrs, [:reset_password_token, :reset_password_sent_at])
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec password_reset_changeset(t, map) :: Ecto.Changeset.t()
|
||||
@spec password_reset_changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def password_reset_changeset(%__MODULE__{} = user, attrs) do
|
||||
password_change_changeset(user, attrs, @password_reset_required_attrs)
|
||||
end
|
||||
|
@ -281,9 +281,9 @@ defmodule Mobilizon.Users do
|
||||
@doc """
|
||||
Returns the list of users.
|
||||
"""
|
||||
@spec list_users(String.t(), integer | nil, integer | nil, atom | nil, atom | nil) ::
|
||||
@spec list_users(String.t(), integer | nil, integer | nil, atom, atom) ::
|
||||
Page.t(User.t())
|
||||
def list_users(email \\ "", page \\ nil, limit \\ nil, sort \\ nil, direction \\ nil)
|
||||
def list_users(email, page, limit \\ nil, sort, direction)
|
||||
|
||||
def list_users("", page, limit, sort, direction) do
|
||||
User
|
||||
@ -452,7 +452,7 @@ defmodule Mobilizon.Users do
|
||||
"""
|
||||
@spec create_push_subscription(map()) ::
|
||||
{:ok, PushSubscription.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_push_subscription(attrs \\ %{}) do
|
||||
def create_push_subscription(attrs) do
|
||||
%PushSubscription{}
|
||||
|> PushSubscription.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
120
lib/service/export/participants/common.ex
Normal file
120
lib/service/export/participants/common.ex
Normal file
@ -0,0 +1,120 @@
|
||||
defmodule Mobilizon.Service.Export.Participants.Common do
|
||||
@moduledoc """
|
||||
Common functions for managing participants export
|
||||
"""
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Events.Participant
|
||||
alias Mobilizon.Events.Participant.Metadata
|
||||
alias Mobilizon.Export
|
||||
alias Mobilizon.Storage.Repo
|
||||
import Mobilizon.Web.Gettext, only: [gettext: 1]
|
||||
|
||||
@spec save_upload(String.t(), String.t(), String.t(), String.t(), String.t()) ::
|
||||
{:ok, Export.t()} | {:error, atom() | Ecto.Changeset.t()}
|
||||
def save_upload(full_path, file_path, reference, file_name, format) do
|
||||
with {:ok, %File.Stat{size: file_size}} <- File.stat(full_path) do
|
||||
%Export{}
|
||||
|> Export.changeset(%{
|
||||
file_size: file_size,
|
||||
file_name: file_name,
|
||||
file_path: file_path,
|
||||
format: format,
|
||||
reference: reference,
|
||||
type: "event_participants"
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Match a participant role to it's translated version
|
||||
"""
|
||||
@spec translate_role(Mobilizon.Events.ParticipantRole.t()) :: String.t()
|
||||
def translate_role(role) do
|
||||
case role do
|
||||
:not_approved ->
|
||||
gettext("Not approved")
|
||||
|
||||
:not_confirmed ->
|
||||
gettext("Not confirmed")
|
||||
|
||||
:rejected ->
|
||||
gettext("Rejected")
|
||||
|
||||
:participant ->
|
||||
gettext("Participant")
|
||||
|
||||
:moderator ->
|
||||
gettext("Moderator")
|
||||
|
||||
:administrator ->
|
||||
gettext("Administrator")
|
||||
|
||||
:creator ->
|
||||
gettext("Creator")
|
||||
end
|
||||
end
|
||||
|
||||
@spec columns :: list(String.t())
|
||||
def columns do
|
||||
[gettext("Participant name"), gettext("Participant status"), gettext("Participant message")]
|
||||
end
|
||||
|
||||
# One hour
|
||||
@expiration 60 * 60
|
||||
|
||||
@doc """
|
||||
Clean outdated files in export folder
|
||||
"""
|
||||
@spec clean_exports(String.t(), String.t(), integer()) :: :ok
|
||||
def clean_exports(format, upload_path, expiration \\ @expiration) do
|
||||
"event_participants"
|
||||
|> Export.outdated(format, expiration)
|
||||