mitgliederverwaltung/lib/mv_web/controllers/import_template_controller.ex

120 lines
3.9 KiB
Elixir

defmodule MvWeb.ImportTemplateController do
@moduledoc """
Serves CSV import templates generated on the fly from the current custom fields.
Two actions provide an English (`en/2`) and a German (`de/2`) template. Each
template has a single header row listing the standard member columns followed
by every existing custom field name (exact match, as the import expects), plus
the importable groups and fee-type columns. A single placeholder example row is
included to illustrate the format.
Both actions require the same authorization as the import page
(`can?(:create, Member)`); unauthorized requests are rejected.
"""
use MvWeb, :controller
alias Mv.Authorization.Actor
alias Mv.Membership.Member
alias Mv.Membership.MembersCSV
alias MvWeb.Authorization
# Standard member columns in template order, with their English and German headers
# and a placeholder example value. Groups and fee type are importable extras.
@columns [
{"first name", "Vorname", "John", "Max"},
{"last name", "Nachname", "Doe", "Mustermann"},
{"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"},
{"country", "Land", "Germany", "Deutschland"},
{"city", "Stadt", "Berlin", "Berlin"},
{"street", "Straße", "Main Street", "Hauptstraße"},
{"house number", "Hausnummer", "1a", "12"},
{"postal_code", "PLZ", "12345", "10115"},
{"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"},
{"exit_date", "Austrittsdatum", "", ""},
{"notes", "Notizen", "", ""},
{"membership_fee_start_date", "Beitragsbeginn", "", ""},
{"Groups", "Gruppen", "", ""},
{"Fee Type", "Beitragsart", "", ""}
]
@spec en(Plug.Conn.t(), map()) :: Plug.Conn.t()
def en(conn, _params) do
serve_template(conn, :en, "member_import_en.csv")
end
@spec de(Plug.Conn.t(), map()) :: Plug.Conn.t()
def de(conn, _params) do
serve_template(conn, :de, "member_import_de.csv")
end
defp serve_template(conn, locale, filename) do
actor = current_actor(conn)
if Authorization.can?(actor, :create, Member) do
csv = build_csv(locale, actor)
send_download(conn, {:binary, csv},
filename: filename,
content_type: "text/csv; charset=utf-8"
)
else
return_forbidden(conn)
end
end
defp build_csv(locale, actor) do
custom_field_names = custom_field_names(actor)
header =
Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names
example =
Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end)
[csv_row(header), csv_row(example)]
|> Enum.join("\n")
end
defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en
defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de
defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en
defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de
defp custom_field_names(actor) do
Mv.Membership.list_custom_fields!(actor: actor)
|> Enum.map(& &1.name)
end
# Serializes a row using the semicolon delimiter (the import auto-detects it),
# quoting any field that contains a delimiter, quote, or newline.
defp csv_row(fields) do
Enum.map_join(fields, ";", &escape_field/1)
end
# Neutralizes spreadsheet formula triggers (the same guard the export writer
# applies) before RFC 4180 quoting, so a custom-field name like
# `=HYPERLINK(...)` is not evaluated when the template is opened.
defp escape_field(field) do
field = field |> to_string() |> MembersCSV.safe_cell()
if String.contains?(field, [";", "\"", "\n", "\r"]) do
"\"" <> String.replace(field, "\"", "\"\"") <> "\""
else
field
end
end
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
defp return_forbidden(conn) do
conn
|> put_status(403)
|> put_resp_content_type("application/json")
|> json(%{error: "Forbidden"})
|> halt()
end
end