feat: add csv export
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-04 16:40:41 +01:00
parent d34ff57531
commit c82f4b7fd7
7 changed files with 487 additions and 1 deletions

View file

@ -158,8 +158,9 @@ defmodule Mv.Authorization.PermissionSets do
"/users/:id",
"/users/:id/edit",
"/users/:id/show/edit",
# Member list
# Member list and CSV export
"/members",
"/members/export.csv",
# Member detail
"/members/:id",
# Custom field values overview
@ -208,6 +209,7 @@ defmodule Mv.Authorization.PermissionSets do
"/users/:id/edit",
"/users/:id/show/edit",
"/members",
"/members/export.csv",
# Create member
"/members/new",
"/members/:id",

View file

@ -0,0 +1,55 @@
defmodule Mv.Membership.CustomFieldValueFormatter do
@moduledoc """
Neutral formatter for custom field values (e.g. CSV export).
Same logic as the member overview Formatter but without Gettext or web helpers,
so it can be used from the Membership context. For boolean: "Yes"/"No";
for date: European format (dd.mm.yyyy).
"""
@doc """
Formats a custom field value for plain text (e.g. CSV).
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
"""
def format_custom_field_value(nil, _custom_field), do: ""
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
format_value_by_type(value, type, custom_field)
end
def format_custom_field_value(value, custom_field) when is_map(value) do
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
format_value_by_type(val, type, custom_field)
end
def format_custom_field_value(value, custom_field) do
format_value_by_type(value, custom_field.value_type, custom_field)
end
defp format_value_by_type(value, :string, _), do: to_string(value)
defp format_value_by_type(value, :integer, _), do: to_string(value)
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
if String.trim(value) == "", do: "", else: value
end
defp format_value_by_type(value, :email, _), do: to_string(value)
defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes"
defp format_value_by_type(value, :boolean, _) when value == false, do: "No"
defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
_ -> value
end
end
defp format_value_by_type(value, _type, _), do: to_string(value)
end

View file

@ -0,0 +1,91 @@
defmodule Mv.Membership.MembersCSV do
@moduledoc """
Exports members to CSV (RFC 4180) as iodata.
Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings;
custom field values use the same formatting logic as the member overview (neutral formatter).
Column order for custom fields follows the key order of the `custom_fields_by_id` map.
"""
alias Mv.Membership.CustomFieldValueFormatter
alias NimbleCSV.RFC4180
@doc """
Exports a list of members to CSV iodata.
- `members` - List of member structs (with optional `custom_field_values` loaded)
- `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`)
- `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order.
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
"""
@spec export(
[struct()],
[String.t()],
%{optional(String.t() | Ecto.UUID.t()) => struct()}
) :: iodata()
def export(members, member_fields, custom_fields_by_id) when is_list(members) do
custom_entries = custom_field_entries(custom_fields_by_id)
header = build_header(member_fields, custom_entries)
rows = Enum.map(members, &build_row(&1, member_fields, custom_entries))
RFC4180.dump_to_iodata([header | rows])
end
defp custom_field_entries(by_id) when is_map(by_id) do
Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end)
end
defp build_header(member_fields, custom_entries) do
member_headers = member_fields
custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end)
member_headers ++ custom_headers
end
defp build_row(member, member_fields, custom_entries) do
member_cells = Enum.map(member_fields, &format_member_field(member, &1))
custom_cells =
Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end)
member_cells ++ custom_cells
end
defp format_member_field(member, field_name) do
key = member_field_key(field_name)
value = Map.get(member, key)
format_member_value(value)
end
defp member_field_key(field_name) when is_binary(field_name) do
try do
String.to_existing_atom(field_name)
rescue
ArgumentError -> field_name
end
end
defp format_member_value(nil), do: ""
defp format_member_value(true), do: "true"
defp format_member_value(false), do: "false"
defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_custom_field(member, custom_field_id, custom_field) do
cfv = find_custom_field_value(member, custom_field_id)
if cfv,
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field),
else: ""
end
defp find_custom_field_value(member, custom_field_id) do
values = Map.get(member, :custom_field_values) || []
id_str = to_string(custom_field_id)
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == id_str or
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
end)
end
end