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