feat: import custom fields via CSV

This commit is contained in:
carla 2026-02-02 11:42:07 +01:00
parent b9dd990f52
commit 3f8797c356
3 changed files with 251 additions and 132 deletions

View file

@ -63,7 +63,9 @@ defmodule Mv.Membership.Import.MemberCSV do
chunks: list(list({pos_integer(), map()})), chunks: list(list({pos_integer(), map()})),
column_map: %{atom() => non_neg_integer()}, column_map: %{atom() => non_neg_integer()},
custom_field_map: %{String.t() => non_neg_integer()}, custom_field_map: %{String.t() => non_neg_integer()},
custom_field_lookup: %{String.t() => %{id: String.t(), value_type: atom()}}, custom_field_lookup: %{
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
},
warnings: list(String.t()) warnings: list(String.t())
} }
@ -79,6 +81,11 @@ defmodule Mv.Membership.Import.MemberCSV do
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Mv.Helpers.SystemActor
# Import FieldTypes for human-readable type labels
alias MvWeb.Translations.FieldTypes
# Configuration constants # Configuration constants
@default_max_errors 50 @default_max_errors 50
@default_chunk_size 200 @default_chunk_size 200
@ -102,6 +109,7 @@ defmodule Mv.Membership.Import.MemberCSV do
- `opts` - Optional keyword list: - `opts` - Optional keyword list:
- `:max_rows` - Maximum number of data rows allowed (default: 1000) - `:max_rows` - Maximum number of data rows allowed (default: 1000)
- `:chunk_size` - Number of rows per chunk (default: 200) - `:chunk_size` - Number of rows per chunk (default: 200)
- `:actor` - Actor for authorization (default: system actor for systemic operations)
## Returns ## Returns
@ -120,9 +128,10 @@ defmodule Mv.Membership.Import.MemberCSV do
def prepare(file_content, opts \\ []) do def prepare(file_content, opts \\ []) do
max_rows = Keyword.get(opts, :max_rows, @default_max_rows) max_rows = Keyword.get(opts, :max_rows, @default_max_rows)
chunk_size = Keyword.get(opts, :chunk_size, @default_chunk_size) chunk_size = Keyword.get(opts, :chunk_size, @default_chunk_size)
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
with {:ok, headers, rows} <- CsvParser.parse(file_content), with {:ok, headers, rows} <- CsvParser.parse(file_content),
{:ok, custom_fields} <- load_custom_fields(), {:ok, custom_fields} <- load_custom_fields(actor),
{:ok, maps, warnings} <- build_header_maps(headers, custom_fields), {:ok, maps, warnings} <- build_header_maps(headers, custom_fields),
:ok <- validate_row_count(rows, max_rows) do :ok <- validate_row_count(rows, max_rows) do
chunks = chunk_rows(rows, maps, chunk_size) chunks = chunk_rows(rows, maps, chunk_size)
@ -142,10 +151,10 @@ defmodule Mv.Membership.Import.MemberCSV do
end end
# Loads all custom fields from the database # Loads all custom fields from the database
defp load_custom_fields do defp load_custom_fields(actor) do
custom_fields = custom_fields =
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.read!() |> Ash.read!(actor: actor)
{:ok, custom_fields} {:ok, custom_fields}
rescue rescue
@ -158,7 +167,7 @@ defmodule Mv.Membership.Import.MemberCSV do
custom_fields custom_fields
|> Enum.reduce(%{}, fn cf, acc -> |> Enum.reduce(%{}, fn cf, acc ->
id_str = to_string(cf.id) id_str = to_string(cf.id)
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type}) Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type, name: cf.name})
end) end)
end end
@ -508,8 +517,13 @@ defmodule Mv.Membership.Import.MemberCSV do
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} -> {:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
# Prepare custom field values for Ash # Prepare custom field values for Ash
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup) case prepare_custom_field_values(custom_attrs, custom_field_lookup) do
{:error, validation_errors} ->
# Custom field validation errors - return first error
first_error = List.first(validation_errors)
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
{:ok, custom_field_values} ->
# Create member with custom field values # Create member with custom field values
member_attrs_with_cf = member_attrs_with_cf =
trimmed_member_attrs trimmed_member_attrs
@ -533,7 +547,9 @@ defmodule Mv.Membership.Import.MemberCSV do
{:error, format_ash_error(error, line_number, email)} {:error, format_ash_error(error, line_number, email)}
{:error, error} -> {:error, error} ->
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}} {:error,
%Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
end
end end
end end
rescue rescue
@ -542,70 +558,160 @@ defmodule Mv.Membership.Import.MemberCSV do
end end
# Prepares custom field values from row map for Ash # Prepares custom field values from row map for Ash
# Returns {:ok, [custom_field_value_maps]} or {:error, [validation_errors]}
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
{values, errors} =
custom_attrs custom_attrs
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end) |> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|> Enum.map(fn {custom_field_id_str, value} -> |> Enum.reduce({[], []}, fn {custom_field_id_str, value}, {acc_values, acc_errors} ->
case Map.get(custom_field_lookup, custom_field_id_str) do case Map.get(custom_field_lookup, custom_field_id_str) do
nil -> nil ->
# Custom field not found, skip # Custom field not found, skip
nil {acc_values, acc_errors}
%{id: custom_field_id, value_type: value_type} -> %{id: custom_field_id, value_type: value_type, name: custom_field_name} ->
%{ case format_custom_field_value(value, value_type, custom_field_name) do
{:ok, formatted_value} ->
value_map = %{
"custom_field_id" => to_string(custom_field_id), "custom_field_id" => to_string(custom_field_id),
"value" => format_custom_field_value(value, value_type) "value" => formatted_value
} }
{[value_map | acc_values], acc_errors}
{:error, reason} ->
{acc_values, [reason | acc_errors]}
end
end end
end) end)
|> Enum.filter(&(&1 != nil))
if Enum.empty?(errors) do
{:ok, Enum.reverse(values)}
else
{:error, Enum.reverse(errors)}
end
end end
defp prepare_custom_field_values(_, _), do: [] defp prepare_custom_field_values(_, _), do: {:ok, []}
# Formats a custom field value according to its type # Formats a custom field value according to its type
# Uses _union_type and _union_value format as expected by Ash # Uses _union_type and _union_value format as expected by Ash
defp format_custom_field_value(value, :string) when is_binary(value) do # Returns {:ok, formatted_value} or {:error, error_message}
%{"_union_type" => "string", "_union_value" => String.trim(value)} defp format_custom_field_value(value, :string, _custom_field_name) when is_binary(value) do
{:ok, %{"_union_type" => "string", "_union_value" => String.trim(value)}}
end end
defp format_custom_field_value(value, :integer) when is_binary(value) do defp format_custom_field_value(value, :integer, custom_field_name) when is_binary(value) do
case Integer.parse(value) do trimmed = String.trim(value)
{int_value, _} -> %{"_union_type" => "integer", "_union_value" => int_value}
:error -> %{"_union_type" => "string", "_union_value" => String.trim(value)} case Integer.parse(trimmed) do
{int_value, ""} ->
# Fully consumed - valid integer
{:ok, %{"_union_type" => "integer", "_union_value" => int_value}}
{_int_value, _remaining} ->
# Not fully consumed - invalid
{:error, format_custom_field_error(custom_field_name, :integer, trimmed)}
:error ->
{:error, format_custom_field_error(custom_field_name, :integer, trimmed)}
end end
end end
defp format_custom_field_value(value, :boolean) when is_binary(value) do defp format_custom_field_value(value, :boolean, custom_field_name) when is_binary(value) do
trimmed = String.trim(value)
lower = String.downcase(trimmed)
bool_value = bool_value =
value case lower do
|> String.trim()
|> String.downcase()
|> case do
"true" -> true "true" -> true
"1" -> true "1" -> true
"yes" -> true "yes" -> true
"ja" -> true "ja" -> true
_ -> false "false" -> false
"0" -> false
"no" -> false
"nein" -> false
_ -> nil
end end
%{"_union_type" => "boolean", "_union_value" => bool_value} if bool_value != nil do
end {:ok, %{"_union_type" => "boolean", "_union_value" => bool_value}}
else
defp format_custom_field_value(value, :date) when is_binary(value) do {:error,
case Date.from_iso8601(String.trim(value)) do format_custom_field_error_with_details(
{:ok, date} -> %{"_union_type" => "date", "_union_value" => date} custom_field_name,
{:error, _} -> %{"_union_type" => "string", "_union_value" => String.trim(value)} :boolean,
trimmed,
gettext("(true/false/1/0/yes/no/ja/nein)")
)}
end end
end end
defp format_custom_field_value(value, :email) when is_binary(value) do defp format_custom_field_value(value, :date, custom_field_name) when is_binary(value) do
%{"_union_type" => "email", "_union_value" => String.trim(value)} trimmed = String.trim(value)
case Date.from_iso8601(trimmed) do
{:ok, date} ->
{:ok, %{"_union_type" => "date", "_union_value" => date}}
{:error, _} ->
{:error,
format_custom_field_error_with_details(
custom_field_name,
:date,
trimmed,
gettext("(ISO-8601 format: YYYY-MM-DD)")
)}
end
end end
defp format_custom_field_value(value, _type) when is_binary(value) do defp format_custom_field_value(value, :email, custom_field_name) when is_binary(value) do
trimmed = String.trim(value)
# Use simple validation: must contain @ and have valid format
# For CSV import, we use a simpler check than EctoCommons.EmailValidator
# to avoid dependencies and keep it fast
if String.contains?(trimmed, "@") and String.length(trimmed) >= 5 and
String.length(trimmed) <= 254 do
# Basic format check: username@domain.tld
if Regex.match?(~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/, trimmed) do
{:ok, %{"_union_type" => "email", "_union_value" => trimmed}}
else
{:error, format_custom_field_error(custom_field_name, :email, trimmed)}
end
else
{:error, format_custom_field_error(custom_field_name, :email, trimmed)}
end
end
defp format_custom_field_value(value, _type, _custom_field_name) when is_binary(value) do
# Default to string if type is unknown # Default to string if type is unknown
%{"_union_type" => "string", "_union_value" => String.trim(value)} {:ok, %{"_union_type" => "string", "_union_value" => String.trim(value)}}
end
# Generates a consistent error message for custom field validation failures
# Uses human-readable field type labels (e.g., "Number" instead of "integer")
defp format_custom_field_error(custom_field_name, value_type, value) do
type_label = FieldTypes.label(value_type)
gettext("custom_field: %{name} expected %{type}, got: %{value}",
name: custom_field_name,
type: type_label,
value: value
)
end
# Generates an error message with additional details (e.g., format hints)
defp format_custom_field_error_with_details(custom_field_name, value_type, value, details) do
type_label = FieldTypes.label(value_type)
gettext("custom_field: %{name} expected %{type} %{details}, got: %{value}",
name: custom_field_name,
type: type_label,
details: details,
value: value
)
end end
# Trims all string values in member attributes # Trims all string values in member attributes

View file

@ -50,9 +50,9 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div> </div>
<%!-- Hide table when form is visible --%> <%!-- Hide table when form is visible --%>
<div :if={!@show_form} id="custom_fields">
<.table <.table
:if={!@show_form} id="custom_fields_table"
id="custom_fields"
rows={@streams.custom_fields} rows={@streams.custom_fields}
row_click={ row_click={
fn {_id, custom_field} -> fn {_id, custom_field} ->
@ -105,11 +105,14 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</:action> </:action>
<:action :let={{_id, custom_field}}> <:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}> <.link phx-click={
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")} {gettext("Delete")}
</.link> </.link>
</:action> </:action>
</.table> </.table>
</div>
<%!-- Delete Confirmation Modal --%> <%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open"> <dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">

View file

@ -138,16 +138,24 @@ defmodule MvWeb.GlobalSettingsLive do
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<.form_section title={gettext("Import Members (CSV)")}> <.form_section title={gettext("Import Members (CSV)")}>
<div role="note" class="alert alert-info mb-4"> <div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div> <div>
<p class="font-semibold"> <p class="font-semibold mb-1">
{gettext("Custom Fields in CSV Import")}
</p>
<p class="text-sm mb-2">
{gettext( {gettext(
"Custom fields must be created in Mila before importing CSV files with custom field columns" "Custom fields must be created in Mila before importing. Use the custom field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
)} )}
</p> </p>
<p class="text-sm mt-2"> <p class="text-sm">
{gettext( <.link
"Use the custom field name as the CSV column header (same normalization as member fields applies)" href="#custom_fields"
)} class="link link-primary"
data-testid="custom-fields-link"
>
{gettext("Manage Custom Fields")}
</.link>
</p> </p>
</div> </div>
</div> </div>
@ -408,8 +416,10 @@ defmodule MvWeb.GlobalSettingsLive do
# Processes CSV upload and starts import # Processes CSV upload and starts import
defp process_csv_upload(socket) do defp process_csv_upload(socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
with {:ok, content} <- consume_and_read_csv(socket), with {:ok, content} <- consume_and_read_csv(socket),
{:ok, import_state} <- MemberCSV.prepare(content) do {:ok, import_state} <- MemberCSV.prepare(content, actor: actor) do
start_import(socket, import_state) start_import(socket, import_state)
else else
{:error, reason} when is_binary(reason) -> {:error, reason} when is_binary(reason) ->