Export participants to different formats

* CSV
* PDF (requires Python dependency `weasyprint`)
* ODS (requires Python dependency `pyexcel_ods3`)

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2021-10-04 18:59:41 +02:00
parent 5dd24e1c9e
commit 0c667b13ae
121 changed files with 10817 additions and 6872 deletions

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

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

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

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