Browse Source

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>
tags/2.0.0-beta.1
Thomas Citharel 1 year ago
parent
commit
0c667b13ae
No known key found for this signature in database GPG Key ID: A061B9DDE0CA0773
100 changed files with 9692 additions and 6305 deletions
  1. +2
    -0
      .gitignore
  2. +1
    -0
      .gitlab-ci.yml
  3. +2
    -1
      .sobelow-skips
  4. +6
    -0
      config/config.exs
  5. +7
    -2
      docker/tests/Dockerfile
  6. +10
    -0
      js/src/graphql/config.ts
  7. +10
    -0
      js/src/graphql/event.ts
  8. +3
    -0
      js/src/types/config.model.ts
  9. +293
    -228
      js/src/views/Event/Participants.vue
  10. +1
    -1
      lib/federation/activity_pub/actions/accept.ex
  11. +4
    -2
      lib/graphql/api/participations.ex
  12. +2
    -1
      lib/graphql/resolvers/config.ex
  13. +2
    -2
      lib/graphql/resolvers/event/utils.ex
  14. +101
    -24
      lib/graphql/resolvers/participant.ex
  15. +11
    -0
      lib/graphql/schema/config.ex
  16. +21
    -0
      lib/graphql/schema/events/participant.ex
  17. +1
    -0
      lib/mobilizon.ex
  18. +8
    -0
      lib/mobilizon/config.ex
  19. +20
    -9
      lib/mobilizon/events/events.ex
  20. +57
    -0
      lib/mobilizon/export.ex
  21. +1
    -0
      lib/mobilizon/users/push_subscription.ex
  22. +4
    -4
      lib/mobilizon/users/user.ex
  23. +3
    -3
      lib/mobilizon/users/users.ex
  24. +120
    -0
      lib/service/export/participants/common.ex
  25. +100
    -0
      lib/service/export/participants/csv.ex
  26. +106
    -0
      lib/service/export/participants/ods.ex
  27. +120
    -0
      lib/service/export/participants/pdf.ex
  28. +28
    -0
      lib/service/python_port.ex
  29. +65
    -0
      lib/service/python_worker.ex
  30. +14
    -0
      lib/service/workers/export_cleaner_worker.ex
  31. +6
    -4
      lib/web/auth/guardian.ex
  32. +35
    -0
      lib/web/controllers/export_controller.ex
  33. +0
    -11
      lib/web/endpoint.ex
  34. +8
    -0
      lib/web/router.ex
  35. +154
    -0
      lib/web/templates/export/event_participants.html.heex
  36. +17
    -0
      lib/web/views/export_view.ex
  37. +2
    -0
      mix.exs
  38. +5
    -0
      mix.lock
  39. +43
    -43
      priv/gettext/activity.pot
  40. +43
    -43
      priv/gettext/ar/LC_MESSAGES/activity.po
  41. +286
    -201
      priv/gettext/ar/LC_MESSAGES/default.po
  42. +44
    -23
      priv/gettext/ar/LC_MESSAGES/errors.po
  43. +43
    -43
      priv/gettext/be/LC_MESSAGES/activity.po
  44. +286
    -201
      priv/gettext/be/LC_MESSAGES/default.po
  45. +44
    -23
      priv/gettext/be/LC_MESSAGES/errors.po
  46. +43
    -43
      priv/gettext/ca/LC_MESSAGES/activity.po
  47. +286
    -201
      priv/gettext/ca/LC_MESSAGES/default.po
  48. +44
    -23
      priv/gettext/ca/LC_MESSAGES/errors.po
  49. +43
    -43
      priv/gettext/cs/LC_MESSAGES/activity.po
  50. +286
    -201
      priv/gettext/cs/LC_MESSAGES/default.po
  51. +44
    -23
      priv/gettext/cs/LC_MESSAGES/errors.po
  52. +43
    -43
      priv/gettext/de/LC_MESSAGES/activity.po
  53. +286
    -201
      priv/gettext/de/LC_MESSAGES/default.po
  54. +44
    -23
      priv/gettext/de/LC_MESSAGES/errors.po
  55. +283
    -198
      priv/gettext/default.pot
  56. +43
    -43
      priv/gettext/en/LC_MESSAGES/activity.po
  57. +286
    -201
      priv/gettext/en/LC_MESSAGES/default.po
  58. +44
    -23
      priv/gettext/en/LC_MESSAGES/errors.po
  59. +44
    -23
      priv/gettext/errors.pot
  60. +43
    -43
      priv/gettext/es/LC_MESSAGES/activity.po
  61. +388
    -303
      priv/gettext/es/LC_MESSAGES/default.po
  62. +190
    -169
      priv/gettext/es/LC_MESSAGES/errors.po
  63. +43
    -43
      priv/gettext/fi/LC_MESSAGES/activity.po
  64. +286
    -201
      priv/gettext/fi/LC_MESSAGES/default.po
  65. +44
    -23
      priv/gettext/fi/LC_MESSAGES/errors.po
  66. +43
    -43
      priv/gettext/fr/LC_MESSAGES/activity.po
  67. +585
    -202
      priv/gettext/fr/LC_MESSAGES/default.po
  68. +53
    -206
      priv/gettext/fr/LC_MESSAGES/errors.po
  69. +43
    -43
      priv/gettext/gl/LC_MESSAGES/activity.po
  70. +286
    -201
      priv/gettext/gl/LC_MESSAGES/default.po
  71. +44
    -23
      priv/gettext/gl/LC_MESSAGES/errors.po
  72. +43
    -43
      priv/gettext/hu/LC_MESSAGES/activity.po
  73. +286
    -201
      priv/gettext/hu/LC_MESSAGES/default.po
  74. +44
    -23
      priv/gettext/hu/LC_MESSAGES/errors.po
  75. +43
    -43
      priv/gettext/id/LC_MESSAGES/activity.po
  76. +286
    -201
      priv/gettext/id/LC_MESSAGES/default.po
  77. +44
    -23
      priv/gettext/id/LC_MESSAGES/errors.po
  78. +43
    -43
      priv/gettext/it/LC_MESSAGES/activity.po
  79. +286
    -201
      priv/gettext/it/LC_MESSAGES/default.po
  80. +44
    -23
      priv/gettext/it/LC_MESSAGES/errors.po
  81. +43
    -43
      priv/gettext/ja/LC_MESSAGES/activity.po
  82. +286
    -201
      priv/gettext/ja/LC_MESSAGES/default.po
  83. +44
    -23
      priv/gettext/ja/LC_MESSAGES/errors.po
  84. +43
    -43
      priv/gettext/nl/LC_MESSAGES/activity.po
  85. +286
    -201
      priv/gettext/nl/LC_MESSAGES/default.po
  86. +44
    -23
      priv/gettext/nl/LC_MESSAGES/errors.po
  87. +43
    -43
      priv/gettext/nn/LC_MESSAGES/activity.po
  88. +286
    -201
      priv/gettext/nn/LC_MESSAGES/default.po
  89. +44
    -23
      priv/gettext/nn/LC_MESSAGES/errors.po
  90. +43
    -43
      priv/gettext/oc/LC_MESSAGES/activity.po
  91. +286
    -201
      priv/gettext/oc/LC_MESSAGES/default.po
  92. +44
    -23
      priv/gettext/oc/LC_MESSAGES/errors.po
  93. +43
    -43
      priv/gettext/pl/LC_MESSAGES/activity.po
  94. +286
    -201
      priv/gettext/pl/LC_MESSAGES/default.po
  95. +44
    -23
      priv/gettext/pl/LC_MESSAGES/errors.po
  96. +43
    -43
      priv/gettext/pt/LC_MESSAGES/activity.po
  97. +286
    -201
      priv/gettext/pt/LC_MESSAGES/default.po
  98. +44
    -23
      priv/gettext/pt/LC_MESSAGES/errors.po
  99. +43
    -43
      priv/gettext/pt_BR/LC_MESSAGES/activity.po
  100. +286
    -201
      priv/gettext/pt_BR/LC_MESSAGES/default.po

+ 2
- 0
.gitignore View File

@@ -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~


+ 1
- 0
.gitlab-ci.yml View File

@@ -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}"


+ 2
- 1
.sobelow-skips View File

@@ -8,4 +8,5 @@
73B351E4CB3AF715AD450A085F5E6304
BBACD7F0BACD4A6D3010C26604671692
6D4D4A4821B93BCFAC9CDBB367B34C4B
5674F0D127852889ED0132DC2F442AAB
5674F0D127852889ED0132DC2F442AAB
1600B7206E47F630D94AB54C360906F0

+ 6
- 0
config/config.exs View File

@@ -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"

+ 7
- 2
docker/tests/Dockerfile View File

@@ -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/

+ 10
- 0
js/src/graphql/config.ts View File

@@ -175,3 +175,13 @@ export const WEB_PUSH = gql`
}
}
`;

export const EVENT_PARTICIPANTS = gql`
query EventParticipants {
config {
exportFormats {
eventParticipants
}
}
}
`;

+ 10
- 0
js/src/graphql/event.ts View File

@@ -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)
}
`;

+ 3
- 0
js/src/types/config.model.ts View File

@@ -102,4 +102,7 @@ export interface IConfig {
enabled: boolean;
publicKey: string;
};
exportFormats: {
eventParticipants: string[];
};
}

+ 293
- 228
js/src/views/Event/Participants.vue View File

@@ -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 {


+ 1
- 1
lib/federation/activity_pub/actions/accept.ex View File

@@ -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")



+ 4
- 2
lib/graphql/api/participations.ex View File

@@ -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


+ 2
- 1
lib/graphql/resolvers/config.ex View File

@@ -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

+ 2
- 2
lib/graphql/resolvers/event/utils.ex View File

@@ -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,


+ 101
- 24
lib/graphql/resolvers/participant.ex View File

@@ -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

+ 11
- 0
lib/graphql/schema/config.ex View File

@@ -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


+ 21
- 0
lib/graphql/schema/events/participant.ex View File

@@ -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

+ 1
- 0
lib/mobilizon.ex View File

@@ -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(


+ 8
- 0
lib/mobilizon/config.ex View File

@@ -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


+ 20
- 9
lib/mobilizon/events/events.ex View File

@@ -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
- 0
lib/mobilizon/export.ex View 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

+ 1
- 0
lib/mobilizon/users/push_subscription.ex View File

@@ -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}})


+ 4
- 4
lib/mobilizon/users/user.ex View File

@@ -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


+ 3
- 3
lib/mobilizon/users/users.ex View File

@@ -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
- 0
lib/service/export/participants/common.ex View 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)
|> 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

+ 100
- 0
lib/service/export/participants/csv.ex View File

@@ -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

+ 106
- 0
lib/service/export/participants/ods.ex View File

@@ -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

+ 120
- 0
lib/service/export/participants/pdf.ex View File

@@ -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

+ 28
- 0
lib/service/python_port.ex View File

@@ -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

+ 65
- 0
lib/service/python_worker.ex View File

@@ -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

+ 14
- 0
lib/service/workers/export_cleaner_worker.ex View File

@@ -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

+ 6
- 4
lib/web/auth/guardian.ex View File

@@ -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


+ 35
- 0
lib/web/controllers/export_controller.ex View File

@@ -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

+ 0
- 11
lib/web/endpoint.ex View File

@@ -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()


+ 8
- 0
lib/web/router.ex View File

@@ -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)



+ 154
- 0
lib/web/templates/export/event_participants.html.heex View File

@@ -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; }