* CSV * PDF (requires Python dependency `weasyprint`) * ODS (requires Python dependency `pyexcel_ods3`) Signed-off-by: Thomas Citharel <tcit@tcit.fr>tags/2.0.0-beta.1
@@ -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" | |||
<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 }, | |||
}" | |||
>{{ event.title }}</router-link | |||
> | |||
{{ $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, | |||
</li> | |||
<li class="is-active"> | |||
<router-link | |||
:to="{ | |||
name: RouteName.PARTICIPANTS, | |||
params: { uuid: event.uuid }, | |||
}" | |||
v-if="props.row.metadata && props.row.metadata.message" | |||
>{{ $t("Participants") }}</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> | |||
</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> | |||
</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> | |||
</div> | |||
<p v-else class="has-text-grey-dark"> | |||
{{ $t("No message") }} | |||
</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> | |||
</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 | |||
<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") | |||
}} | |||
</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> | |||
</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> | |||
</template> | |||
</b-table> | |||
</section> | |||
</main> | |||
</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")} | |||
{:same_role, true} -> | |||
{:error, dgettext("errors", "Participant already has role %{role}", role: new_role)} | |||
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) | |||
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 | |||
@@ -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() | |||
@@ -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) | |||
|> Enum.each(&remove_export(&1, upload_path)) | |||
end | |||
defp remove_export(%Export{file_path: filename} = export, upload_path) do | |||
full_path = upload_path <> filename | |||
File.rm(full_path) | |||
Repo.delete!(export) | |||
end | |||
@spec to_list({Participant.t(), Actor.t()}) :: list(String.t()) | |||
def to_list( | |||
{%Participant{role: role, metadata: metadata}, | |||
%Actor{domain: nil, preferred_username: "anonymous"}} | |||
) do | |||
[gettext("Anonymous participant"), translate_role(role), convert_metadata(metadata)] | |||
end | |||
def to_list({%Participant{role: role, metadata: metadata}, %Actor{} = actor}) do | |||
[Actor.display_name_and_username(actor), translate_role(role), convert_metadata(metadata)] | |||
end | |||
@spec convert_metadata(Metadata.t() | nil) :: String.t() | |||
defp convert_metadata(%Metadata{message: message}) when is_binary(message) do | |||
message | |||
end | |||
defp convert_metadata(_), do: "" | |||
@spec export_modules :: list(module()) | |||
def export_modules do | |||
export_config = Application.get_env(:mobilizon, :exports) | |||
Keyword.get(export_config, :formats, []) | |||
end | |||
@spec enabled_formats :: list(String.t()) | |||
def enabled_formats do | |||
export_modules() | |||
|> Enum.map(& &1.extension()) | |||
end | |||
@spec export_enabled?(module()) :: boolean | |||
def export_enabled?(type) do | |||
export_config = Application.get_env(:mobilizon, :exports) | |||
formats = Keyword.get(export_config, :formats, []) | |||
type in formats | |||
end | |||
end |
@@ -0,0 +1,100 @@ | |||
defmodule Mobilizon.Service.Export.Participants.CSV do | |||
@moduledoc """ | |||
Export a list of participants to CSV | |||
""" | |||
alias Mobilizon.Events | |||
alias Mobilizon.Events.Event | |||
alias Mobilizon.Export | |||
alias Mobilizon.Storage.Repo | |||
alias Mobilizon.Web.Gettext | |||
import Mobilizon.Web.Gettext, only: [gettext: 2] | |||
import Mobilizon.Service.Export.Participants.Common, | |||
only: [save_upload: 5, columns: 0, to_list: 1, clean_exports: 2, export_enabled?: 1] | |||
@upload_path "uploads/exports/csv/" | |||
@extension "csv" | |||
def extension do | |||
@extension | |||
end | |||
@spec export(Event.t(), Keyword.t()) :: | |||
{:ok, String.t()} | {:error, :failed_to_save_upload | :export_dependency_not_installed} | |||
def export(%Event{id: event_id} = event, options \\ []) do | |||
if ready?() do | |||
filename = "#{ShortUUID.encode!(Ecto.UUID.generate())}.csv" | |||
full_path = @upload_path <> filename | |||
file = File.open!(full_path, [:write, :utf8]) | |||
case Repo.transaction( | |||
fn -> | |||
event_id | |||
|> Events.participant_for_event_export_query(Keyword.get(options, :roles, [])) | |||
|> Repo.stream() | |||
|> Stream.map(&to_list/1) | |||
|> NimbleCSV.RFC4180.dump_to_iodata() | |||
|> (fn stream -> Stream.concat([Enum.join(columns(), ","), "\n"], stream) end).() | |||
|> Stream.each(fn line -> IO.write(file, line) end) | |||
|> Stream.run() | |||
with {:error, err} <- save_csv_upload(full_path, filename, event) do | |||
Repo.rollback(err) | |||
end | |||
end, | |||
timeout: :infinity | |||
) do | |||
{:error, _err} -> | |||
File.rm!(full_path) | |||
{:error, :failed_to_save_upload} | |||
{:ok, _ok} -> | |||
{:ok, filename} | |||
end | |||
else | |||
{:error, :export_dependency_not_installed} | |||
end | |||
end | |||
@spec save_csv_upload(String.t(), String.t(), Event.t()) :: | |||
{:ok, Export.t()} | {:error, atom() | Ecto.Changeset.t()} | |||
defp save_csv_upload(full_path, filename, %Event{id: event_id, title: title}) do | |||
Gettext.gettext_comment( | |||
"File name template for exported list of participants. Should NOT contain spaces. Make sure the output is going to be something standardized that is acceptable as a file name on most systems." | |||
) | |||
save_upload( | |||
full_path, | |||
filename, | |||
to_string(event_id), | |||
gettext("%{event}_participants", event: title) <> ".csv", | |||
"csv" | |||
) | |||
end | |||
@doc """ | |||
Clean outdated files in export folder | |||
""" | |||
@spec clean_exports :: :ok | |||
def clean_exports do | |||
clean_exports("csv", @upload_path) | |||
end | |||
@spec dependencies_ok? :: boolean | |||
def dependencies_ok? do | |||
true | |||
end | |||
@spec enabled? :: boolean | |||
def enabled? do | |||
export_enabled?(__MODULE__) | |||
end | |||
@spec ready? :: boolean | |||
def ready? do | |||
enabled?() && dependencies_ok?() | |||
end | |||
end |
@@ -0,0 +1,106 @@ | |||
defmodule Mobilizon.Service.Export.Participants.ODS do | |||
@moduledoc """ | |||
Export a list of participants to ODS | |||
""" | |||
alias Mobilizon.Events | |||
alias Mobilizon.Events.Event | |||
alias Mobilizon.Export | |||
alias Mobilizon.PythonWorker | |||
alias Mobilizon.Storage.Repo | |||
alias Mobilizon.Web.Gettext, as: GettextBackend | |||
import Mobilizon.Web.Gettext, only: [gettext: 2] | |||
import Mobilizon.Service.Export.Participants.Common, | |||
only: [save_upload: 5, to_list: 1, clean_exports: 2, columns: 0, export_enabled?: 1] | |||
@upload_path "uploads/exports/ods/" | |||
@extension "ods" | |||
def extension do | |||
@extension | |||
end | |||
@spec export(Event.t(), Keyword.t()) :: | |||
{:ok, String.t()} | {:error, :failed_to_save_upload | :export_dependency_not_installed} | |||
def export(%Event{id: event_id} = event, options \\ []) do | |||
if ready?() do | |||
filename = "#{ShortUUID.encode!(Ecto.UUID.generate())}.ods" | |||
full_path = @upload_path <> filename | |||
case Repo.transaction( | |||
fn -> | |||
content = | |||
event_id | |||
|> Events.participant_for_event_export_query(Keyword.get(options, :roles, [])) | |||
|> Repo.all() | |||
|> Enum.map(&to_list/1) | |||
|> (fn data -> Enum.concat([columns()], data) end).() | |||
|> generate_ods() | |||
File.write!(full_path, content) | |||
with {:error, err} <- save_ods_upload(full_path, filename, event) do | |||
Repo.rollback(err) | |||
end | |||
end, | |||
timeout: :infinity | |||
) do | |||
{:error, _err} -> | |||
File.rm!(full_path) | |||
{:error, :failed_to_save_upload} | |||
{:ok, _ok} -> | |||
{:ok, filename} | |||
end | |||
else | |||
{:error, :export_dependency_not_installed} | |||
end | |||
end | |||
defp generate_ods(data) do | |||
data | |||
|> Jason.encode!() | |||
|> PythonWorker.generate_ods() | |||
end | |||
@spec save_ods_upload(String.t(), String.t(), Event.t()) :: | |||
{:ok, Export.t()} | {:error, atom() | Ecto.Changeset.t()} | |||
defp save_ods_upload(full_path, filename, %Event{id: event_id, title: title}) do | |||
GettextBackend.gettext_comment( | |||
"File name template for exported list of participants. Should NOT contain spaces. Make sure the output is going to be something standardized that is acceptable as a file name on most systems." | |||
) | |||
save_upload( | |||
full_path, | |||
filename, | |||
to_string(event_id), | |||
gettext("%{event}_participants", event: title) <> ".ods", | |||
"ods" | |||
) | |||
end | |||
@doc """ | |||
Clean outdated files in export folder | |||
""" | |||
@spec clean_exports :: :ok | |||
def clean_exports do | |||
clean_exports("ods", @upload_path) | |||
end | |||
@spec dependencies_ok? :: boolean | |||
def dependencies_ok? do | |||
PythonWorker.has_module("pyexcel_ods3") | |||
end | |||
@spec enabled? :: boolean | |||
def enabled? do | |||
export_enabled?(__MODULE__) | |||
end | |||
@spec ready? :: boolean | |||
def ready? do | |||
enabled?() && dependencies_ok?() | |||
end | |||
end |
@@ -0,0 +1,120 @@ | |||
defmodule Mobilizon.Service.Export.Participants.PDF do | |||
@moduledoc """ | |||
Export a list of participants to PDF | |||
""" | |||
alias Mobilizon.Events | |||
alias Mobilizon.Events.Event | |||
alias Mobilizon.Export | |||
alias Mobilizon.PythonWorker | |||
alias Mobilizon.Storage.Repo | |||
alias Mobilizon.Web.ExportView | |||
alias Mobilizon.Web.Gettext, as: GettextBackend | |||
alias Phoenix.HTML.Safe | |||
import Mobilizon.Web.Gettext, only: [gettext: 2] | |||
import Mobilizon.Service.Export.Participants.Common, | |||
only: [save_upload: 5, columns: 0, to_list: 1, clean_exports: 2, export_enabled?: 1] | |||
@upload_path "uploads/exports/pdf/" | |||
@extension "pdf" | |||
def extension do | |||
@extension | |||
end | |||
@spec export(Event.t(), Keyword.t()) :: | |||
{:ok, String.t()} | {:error, :failed_to_save_upload | :export_dependency_not_installed} | |||
def export(%Event{id: event_id} = event, options \\ []) do | |||
if ready?() do | |||
filename = "#{ShortUUID.encode!(Ecto.UUID.generate())}.pdf" | |||
full_path = @upload_path <> filename | |||
case Repo.transaction( | |||
fn -> | |||
content = | |||
event_id | |||
|> Events.participant_for_event_export_query(Keyword.get(options, :roles, [])) | |||
|> Repo.all() | |||
|> Enum.map(&to_list/1) | |||
|> render_template(event, Keyword.get(options, :locale, "en")) | |||
|> generate_pdf() | |||
File.write!(full_path, content) | |||
with {:error, err} <- save_pdf_upload(full_path, filename, event) do | |||
Repo.rollback(err) | |||
end | |||
end, | |||
timeout: :infinity | |||
) do | |||
{:error, _err} -> | |||
File.rm!(full_path) | |||
{:error, :failed_to_save_upload} | |||
{:ok, _ok} -> | |||
{:ok, filename} | |||
end | |||
else | |||
{:error, :export_dependency_not_installed} | |||
end | |||
end | |||
@spec render_template(list(), Event.t(), String.t()) :: String.t() | |||
defp render_template(data, %Event{} = event, locale) do | |||
Gettext.put_locale(locale) | |||
ExportView.render("event_participants.html", | |||
data: data, | |||
columns: columns(), | |||
event: event, | |||
locale: locale | |||
) | |||
|> Safe.to_iodata() | |||
|> IO.iodata_to_binary() | |||
end | |||
defp generate_pdf(html) do | |||
PythonWorker.generate_pdf(html) | |||
end | |||
@spec save_pdf_upload(String.t(), String.t(), Event.t()) :: | |||
{:ok, Export.t()} | {:error, atom() | Ecto.Changeset.t()} | |||
defp save_pdf_upload(full_path, filename, %Event{id: event_id, title: title}) do | |||
GettextBackend.gettext_comment( | |||
"File name template for exported list of participants. Should NOT contain spaces. Make sure the output is going to be something standardized that is acceptable as a file name on most systems." | |||
) | |||
save_upload( | |||
full_path, | |||
filename, | |||
to_string(event_id), | |||
gettext("%{event}_participants", event: title) <> ".pdf", | |||
"pdf" | |||
) | |||
end | |||
@doc """ | |||
Clean outdated files in export folder | |||
""" | |||
@spec clean_exports :: :ok | |||
def clean_exports do | |||
clean_exports("pdf", @upload_path) | |||
end | |||
@spec dependencies_ok? :: boolean | |||
def dependencies_ok? do | |||
PythonWorker.has_module("weasyprint") | |||
end | |||
@spec enabled? :: boolean | |||
def enabled? do | |||
export_enabled?(__MODULE__) | |||
end | |||
@spec ready? :: boolean | |||
def ready? do | |||
enabled?() && dependencies_ok?() | |||
end | |||
end |
@@ -0,0 +1,28 @@ | |||
defmodule Mobilizon.PythonPort do | |||
@moduledoc """ | |||
Port to use Python modules from Elixir | |||
""" | |||
use Export.Python | |||
@doc """ | |||
## Parameters | |||
- path: directory to include in python path | |||
""" | |||
@spec python_instance(String.t()) :: pid | |||
def python_instance(path) do | |||
python = "/usr/bin/python3" | |||
{:ok, pid} = Python.start(python: python, python_path: path) | |||
pid | |||
end | |||
@doc """ | |||
Call python function using MFA format | |||
""" | |||
@spec call_python(pid, binary, binary, list) :: any | |||
def call_python(pid, module, function, arguments \\ []) do | |||
Python.call(pid, module, function, arguments) | |||
end | |||
end |
@@ -0,0 +1,65 @@ | |||
defmodule Mobilizon.PythonWorker do | |||
@moduledoc """ | |||
Genserver to handle an instance of Python handling the calls to `Mobilizon.PythonPort`. | |||
""" | |||
use GenServer | |||
use Export.Python | |||
alias Mobilizon.PythonPort | |||
@spec start_link(any) :: :ignore | {:error, any} | {:ok, pid} | |||
def start_link(_) do | |||
GenServer.start_link(__MODULE__, [], name: __MODULE__) | |||
end | |||
@spec init(any) :: {:ok, %{python_pid: pid}} | |||
def init(_) do | |||
path = Path.join([:code.priv_dir(:mobilizon), "python"]) | |||
pid = PythonPort.python_instance(path) | |||
{:ok, %{python_pid: pid}} | |||
end | |||
def terminate(_reason, %{python_pid: pid}) do | |||
Python.stop(pid) | |||
end | |||
@spec generate_pdf(String.t()) :: any | |||
def generate_pdf(html) do | |||
GenServer.call(__MODULE__, %{html: html, format: :pdf}) | |||
end | |||
@spec generate_ods(String.t()) :: any | |||
def generate_ods(data) do | |||
GenServer.call(__MODULE__, %{data: data, format: :ods}) | |||
end | |||
@spec has_module(String.t()) :: any | |||
def has_module(module) do | |||
GenServer.call(__MODULE__, %{module: module}) | |||
end | |||
@spec handle_call( | |||
%{html: String.t(), format: :pdf} | %{data: String.t(), format: :ods}, | |||
any(), | |||
map() | |||
) :: {:reply, String.t(), map()} | |||
def handle_call(%{html: html, format: :pdf}, _from, %{python_pid: pid} = state) do | |||
res = PythonPort.call_python(pid, "pdf", "generate", [html]) | |||
{:reply, res, state} | |||
end | |||
def handle_call(%{data: data, format: :ods}, _from, %{python_pid: pid} = state) do | |||
res = PythonPort.call_python(pid, "ods", "generate", [data]) | |||
{:reply, res, state} | |||
end | |||
def handle_call(%{module: module}, _from, %{python_pid: pid} = state) do | |||
res = PythonPort.call_python(pid, "module", "has_package", [module]) | |||
{:reply, res, state} | |||
end | |||
end |
@@ -0,0 +1,14 @@ | |||
defmodule Mobilizon.Service.Workers.ExportCleanerWorker do | |||
@moduledoc """ | |||
Worker to clean exports | |||
""" | |||
use Oban.Worker, queue: "background" | |||
import Mobilizon.Service.Export.Participants.Common, only: [export_modules: 0] | |||
@impl Oban.Worker | |||
@spec perform(Oban.Job.t()) :: :ok | |||
def perform(%Job{}) do | |||
Enum.each(export_modules(), & &1.clean_exports()) | |||
end | |||
end |
@@ -53,28 +53,30 @@ defmodule Mobilizon.Web.Auth.Guardian do | |||
end | |||
end | |||
@spec on_verify(any(), any(), any()) :: {:ok, any()} | |||
@spec on_verify(any(), any(), any()) :: {:ok, map()} | {:error, :token_not_found} | |||
def on_verify(claims, token, _options) do | |||
with {:ok, _} <- Guardian.DB.on_verify(claims, token) do | |||
{:ok, claims} | |||
end | |||
end | |||
@spec on_revoke(any(), any(), any()) :: {:ok, any()} | |||
@spec on_revoke(any(), any(), any()) :: {:ok, map()} | {:error, :could_not_revoke_token} | |||
def on_revoke(claims, token, _options) do | |||
with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do | |||
{:ok, claims} | |||
end | |||
end | |||
@spec on_refresh({any(), any()}, {any(), any()}, any()) :: {:ok, {any(), any()}, {any(), any()}} | |||
@spec on_refresh({any(), any()}, {any(), any()}, any()) :: | |||
{:ok, {String.t(), map()}, {String.t(), map()}} | {:error, any()} | |||
def on_refresh({old_token, old_claims}, {new_token, new_claims}, _options) do | |||
with {:ok, _, _} <- Guardian.DB.on_refresh({old_token, old_claims}, {new_token, new_claims}) do | |||
{:ok, {old_token, old_claims}, {new_token, new_claims}} | |||
end | |||
end | |||
@spec on_exchange(any(), any(), any()) :: {:ok, {any(), any()}, {any(), any()}} | |||
@spec on_exchange(any(), any(), any()) :: | |||
{:ok, {String.t(), map()}, {String.t(), map()}} | {:error, any()} | |||
def on_exchange(old_stuff, new_stuff, options), do: on_refresh(old_stuff, new_stuff, options) | |||
# def build_claims(claims, _resource, opts) do | |||
@@ -0,0 +1,35 @@ | |||
defmodule Mobilizon.Web.ExportController do | |||
@moduledoc """ | |||
Controller to serve exported files | |||
""" | |||
use Mobilizon.Web, :controller | |||
plug(:put_layout, false) | |||
action_fallback(Mobilizon.Web.FallbackController) | |||
alias Mobilizon.Export | |||
import Mobilizon.Service.Export.Participants.Common, only: [enabled_formats: 0] | |||
import Mobilizon.Web.Gettext, only: [dgettext: 3] | |||
# sobelow_skip ["Traversal.SendDownload"] | |||
@spec export(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t() | |||
def export(conn, %{"format" => format, "file" => file}) do | |||
if format in enabled_formats() do | |||
case Export.get_export(file, "event_participants", format) do | |||
%Export{file_name: file_name, file_path: file_path} -> | |||
local_path = "uploads/exports/#{format}/#{file_path}" | |||
# We're using encode: false to disable escaping the filename with URI.encode_www_form/1 | |||
# but it may introduce an security issue if the event title wasn't properly sanitized | |||
# https://github.com/phoenixframework/phoenix/pull/3344 | |||
# https://owasp.org/www-community/attacks/HTTP_Response_Splitting | |||
send_download(conn, {:file, local_path}, filename: file_name, encode: false) | |||
nil -> | |||
{:error, :not_found} | |||
end | |||
else | |||
{:error, | |||
dgettext("errors", "Export to format %{format} is not enabled on this instance", | |||
format: format | |||
)} | |||
end | |||
end | |||
end |
@@ -77,17 +77,6 @@ defmodule Mobilizon.Web.Endpoint do | |||
plug(Plug.MethodOverride) | |||
plug(Plug.Head) | |||
# The session will be stored in the cookie and signed, | |||
# this means its contents can be read but not tampered with. | |||
# Set :encryption_salt if you would also like to encrypt it. | |||
plug( | |||
Plug.Session, | |||
store: :cookie, | |||
key: "_mobilizon_key", | |||
signing_salt: "F9CCTF22" | |||
) | |||
plug(Mobilizon.Web.Router) | |||
@spec websocket_url :: String.t() | |||
@@ -61,6 +61,9 @@ defmodule Mobilizon.Web.Router do | |||
plug(:accepts, ["atom", "ics", "html"]) | |||
end | |||
pipeline :exports do | |||
end | |||
pipeline :browser do | |||
plug(Plug.Static, at: "/", from: "priv/static") | |||
@@ -78,6 +81,11 @@ defmodule Mobilizon.Web.Router do | |||
pipeline :remote_media do | |||
end | |||
scope "/exports", Mobilizon.Web do | |||
pipe_through(:browser) | |||
get("/:format/:file", ExportController, :export) | |||
end | |||
scope "/api" do | |||
pipe_through(:graphql) | |||
@@ -0,0 +1,154 @@ | |||
<!DOCTYPE html> | |||
<html> | |||
<head> | |||
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> | |||
<style> | |||
table { | |||
border: 1px solid #bdbdbd; | |||
border-collapse: collapse; | |||
width: 100%; } | |||
th, | |||
td, | |||
table caption { | |||
padding: 0.75rem; | |||
text-align: left; | |||
text-align: start; | |||
/* 1 */ } | |||
[dir="rtl"] th, | |||
[dir="rtl"] td, | |||
[dir="rtl"] table caption { | |||
text-align: right; | |||
text-align: start; | |||
/* 1 */ } | |||
td { | |||
vertical-align: text-top; | |||
} | |||
th { | |||
vertical-align: bottom; } | |||
th[scope="col"] { | |||
background-color: #024488; | |||
color: #fff; } | |||
dl { | |||
display: flex; | |||
flex-flow: row wrap; | |||
} | |||
dt { | |||
flex-basis: 20%; | |||
padding: 2px 4px; | |||
text-align: right; | |||
} | |||
dd { | |||
flex-basis: 70%; | |||
flex-grow: 1; | |||
margin: 0; | |||
padding: 2px 4px; | |||
} | |||
dl dt { | |||
font-weight: bold; } | |||
dl dd + dt { | |||
margin-top: 0.5em; } | |||