feat(import): serve dynamic CSV import templates reflecting current custom fields
This commit is contained in:
parent
00e1624ee4
commit
a93dd9d535
5 changed files with 238 additions and 13 deletions
120
lib/mv_web/controllers/import_template_controller.ex
Normal file
120
lib/mv_web/controllers/import_template_controller.ex
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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
|
||||
|
|
@ -44,20 +44,12 @@ defmodule MvWeb.ImportLive.Components do
|
|||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_en.csv"}
|
||||
download="member_import_en.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
<.link href={~p"/admin/import/template/en"} class="link link-primary">
|
||||
{gettext("English Template")}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_de.csv"}
|
||||
download="member_import_de.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
<.link href={~p"/admin/import/template/de"} class="link link-primary">
|
||||
{gettext("German Template")}
|
||||
</.link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ defmodule MvWeb.Router do
|
|||
# Import (Admin only)
|
||||
live "/admin/import", ImportLive
|
||||
|
||||
# Dynamic CSV import templates (admin only; generated from current custom fields)
|
||||
get "/admin/import/template/en", ImportTemplateController, :en
|
||||
get "/admin/import/template/de", ImportTemplateController, :de
|
||||
|
||||
post "/members/export.csv", MemberExportController, :export
|
||||
post "/members/export.pdf", MemberPdfExportController, :export
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
|
|
|
|||
104
test/mv_web/controllers/import_template_controller_test.exs
Normal file
104
test/mv_web/controllers/import_template_controller_test.exs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
defmodule MvWeb.ImportTemplateControllerTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, custom_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Lieblingsfarbe", value_type: :string})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
%{conn: conn, custom_field: custom_field}
|
||||
end
|
||||
|
||||
describe "authenticated EN template" do
|
||||
setup %{conn: conn} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
|
||||
end
|
||||
|
||||
test "returns CSV with English headers and current custom fields", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
|
||||
assert response_content_type(conn, :csv) =~ "text/csv"
|
||||
body = response(conn, 200)
|
||||
|
||||
header = body |> String.split("\n") |> List.first()
|
||||
assert header =~ "email"
|
||||
# EN headers use the canonical English variant from HeaderMapper, not the
|
||||
# underscore form, so the template stays faithful to the documented variant list.
|
||||
assert header =~ "first name"
|
||||
assert header =~ "last name"
|
||||
refute header =~ "first_name"
|
||||
assert header =~ "house number"
|
||||
refute header =~ "house_number"
|
||||
assert header =~ "Lieblingsfarbe"
|
||||
|
||||
assert get_resp_header(conn, "content-disposition")
|
||||
|> Enum.any?(&(&1 =~ "member_import_en.csv"))
|
||||
end
|
||||
|
||||
test "neutralizes formula-injection in a custom field header", %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, _} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "=cmd|'/c calc'!A1",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
body = response(conn, 200)
|
||||
header = body |> String.split("\n") |> List.first()
|
||||
|
||||
# The dangerous cell must be prefixed with a single quote so spreadsheet
|
||||
# software does not evaluate it as a formula, matching the export writer.
|
||||
refute header =~ ~r/(^|;)=cmd/
|
||||
assert header =~ "'=cmd|'/c calc'!A1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "authenticated DE template" do
|
||||
setup %{conn: conn} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
|
||||
end
|
||||
|
||||
test "returns CSV with German headers and current custom fields", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/de")
|
||||
|
||||
body = response(conn, 200)
|
||||
header = body |> String.split("\n") |> List.first()
|
||||
|
||||
assert header =~ "E-Mail"
|
||||
assert header =~ "Vorname"
|
||||
assert header =~ "Lieblingsfarbe"
|
||||
|
||||
assert get_resp_header(conn, "content-disposition")
|
||||
|> Enum.any?(&(&1 =~ "member_import_de.csv"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "authorization" do
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated request does not receive a CSV", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
|
||||
refute conn.status == 200
|
||||
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
|
||||
refute to_string(conn.resp_body) =~ "email"
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "user without import permission is forbidden", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
|
||||
refute conn.status == 200
|
||||
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
|
||||
refute to_string(conn.resp_body) =~ "email"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -240,10 +240,15 @@ defmodule MvWeb.ImportLiveTest do
|
|||
assert has_element?(view, "[data-testid='start-import-button']")
|
||||
end
|
||||
|
||||
test "template links and file input are present", %{conn: conn} do
|
||||
test "template links point to the dynamic import template routes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
assert has_element?(view, "a[href='/admin/import/template/en']")
|
||||
assert has_element?(view, "a[href='/admin/import/template/de']")
|
||||
refute has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||
end
|
||||
|
||||
test "file input is present", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
|
||||
assert has_element?(view, "label[for='csv_file']")
|
||||
assert has_element?(view, "#csv_file_help")
|
||||
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue