Compare commits

...

1 commit

Author SHA1 Message Date
930c039c5d
CSV export: robust apply_export_filters, single custom_field_ids_union, string boolean_filters, more tests
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-04 20:51:00 +01:00
3 changed files with 172 additions and 18 deletions

View file

@ -388,20 +388,25 @@ defmodule Mv.Membership.MemberExport do
- `opts` - Map with `:selected_ids`, `:cycle_status_filter`, `:show_current_cycle`, `:boolean_filters`. - `opts` - Map with `:selected_ids`, `:cycle_status_filter`, `:show_current_cycle`, `:boolean_filters`.
- `custom_fields_by_id` - Map of custom field id => custom field struct (for boolean filter resolution). - `custom_fields_by_id` - Map of custom field id => custom field struct (for boolean filter resolution).
When `opts.selected_ids` is not empty, returns `members` unchanged. Otherwise applies When `opts.selected_ids` is not empty, returns `members` unchanged (selected_ids
cycle status filter and boolean custom field filters. override filters). Otherwise applies cycle status filter and boolean custom field filters.
Uses `Map.get(opts, :selected_ids, [])` so that `nil` or a missing key is treated as
"export all" and filters are applied.
""" """
@spec apply_export_filters([struct()], map(), map()) :: [struct()] @spec apply_export_filters([struct()], map(), map()) :: [struct()]
def apply_export_filters(members, opts, custom_fields_by_id) do def apply_export_filters(members, opts, custom_fields_by_id) do
if opts[:selected_ids] != [] do selected_ids = Map.get(opts, :selected_ids, [])
members
else if Enum.empty?(selected_ids) do
members members
|> apply_cycle_status_filter(opts[:cycle_status_filter], opts[:show_current_cycle]) |> apply_cycle_status_filter(opts[:cycle_status_filter], opts[:show_current_cycle])
|> Index.apply_boolean_custom_field_filters( |> Index.apply_boolean_custom_field_filters(
Map.get(opts, :boolean_filters, %{}), Map.get(opts, :boolean_filters, %{}),
Map.values(custom_fields_by_id) Map.values(custom_fields_by_id)
) )
else
members
end end
end end

View file

@ -66,6 +66,9 @@ defmodule MvWeb.MemberExportController do
defp parse_and_validate(params) do defp parse_and_validate(params) do
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields")) member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
{selectable_member_fields, computed_fields} = split_member_fields(member_fields) {selectable_member_fields, computed_fields} = split_member_fields(member_fields)
custom_field_ids = filter_valid_uuids(extract_list(params, "custom_field_ids"))
boolean_filters = extract_boolean_filters(params)
custom_field_ids_union = (custom_field_ids ++ Map.keys(boolean_filters)) |> Enum.uniq()
%{ %{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
@ -73,16 +76,19 @@ defmodule MvWeb.MemberExportController do
selectable_member_fields: selectable_member_fields, selectable_member_fields: selectable_member_fields,
computed_fields: computed_fields:
computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")), computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")), custom_field_ids: custom_field_ids,
custom_field_ids_union: custom_field_ids_union,
query: extract_string(params, "query"), query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"), sort_field: extract_string(params, "sort_field"),
sort_order: extract_sort_order(params), sort_order: extract_sort_order(params),
show_current_cycle: extract_boolean(params, "show_current_cycle"), show_current_cycle: extract_boolean(params, "show_current_cycle"),
cycle_status_filter: extract_cycle_status_filter(params), cycle_status_filter: extract_cycle_status_filter(params),
boolean_filters: extract_boolean_filters(params) boolean_filters: boolean_filters
} }
end end
# Only paid and unpaid are supported for list/export filter. :suspended exists in the
# domain (e.g. membership fee status display) but is not used as a filter in the member index.
defp extract_cycle_status_filter(params) do defp extract_cycle_status_filter(params) do
case Map.get(params, "cycle_status_filter") do case Map.get(params, "cycle_status_filter") do
"paid" -> :paid "paid" -> :paid
@ -91,13 +97,15 @@ defmodule MvWeb.MemberExportController do
end end
end end
# Normalizes values so that "true"/"false" from query/form encoding are accepted as well as JSON booleans.
defp extract_boolean_filters(params) do defp extract_boolean_filters(params) do
case Map.get(params, "boolean_filters") do case Map.get(params, "boolean_filters") do
map when is_map(map) -> map when is_map(map) ->
map map
|> Enum.filter(fn {k, v} -> |> Enum.filter(fn {k, v} ->
is_binary(k) and is_boolean(v) and match?({:ok, _}, Ecto.UUID.cast(k)) is_binary(k) and match?({:ok, _}, Ecto.UUID.cast(k)) and boolean_value?(v)
end) end)
|> Enum.map(fn {k, v} -> {k, normalize_boolean_value(v)} end)
|> Enum.into(%{}) |> Enum.into(%{})
_ -> _ ->
@ -105,6 +113,14 @@ defmodule MvWeb.MemberExportController do
end end
end end
defp boolean_value?(v) when is_boolean(v), do: true
defp boolean_value?(v) when v in ["true", "false"], do: true
defp boolean_value?(_), do: false
defp normalize_boolean_value(v) when is_boolean(v), do: v
defp normalize_boolean_value("true"), do: true
defp normalize_boolean_value("false"), do: false
defp split_member_fields(member_fields) do defp split_member_fields(member_fields) do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
@ -181,10 +197,7 @@ defmodule MvWeb.MemberExportController do
parsed parsed
|> ensure_sort_custom_field_loaded() |> ensure_sort_custom_field_loaded()
custom_field_ids_union = with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids_union, actor),
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do {:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
columns = build_columns(conn, parsed, custom_fields_by_id) columns = build_columns(conn, parsed, custom_fields_by_id)
csv_iodata = MembersCSV.export(members, columns) csv_iodata = MembersCSV.export(members, columns)
@ -202,13 +215,16 @@ defmodule MvWeb.MemberExportController do
end end
end end
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, custom_field_ids_union: union, sort_field: sort_field} = parsed) do
case extract_sort_custom_field_id(sort_field) do case extract_sort_custom_field_id(sort_field) do
nil -> nil ->
parsed parsed
id -> id ->
%{parsed | custom_field_ids: Enum.uniq([id | ids])} %{parsed |
custom_field_ids: Enum.uniq([id | ids]),
custom_field_ids_union: Enum.uniq([id | union])
}
end end
end end
@ -260,9 +276,6 @@ defmodule MvWeb.MemberExportController do
defp load_members_for_export(actor, parsed, custom_fields_by_id) do defp load_members_for_export(actor, parsed, custom_fields_by_id) do
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1) select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
custom_field_ids_union =
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
need_cycles = need_cycles =
(parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or (parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or
parsed.cycle_status_filter != nil parsed.cycle_status_filter != nil
@ -277,7 +290,7 @@ defmodule MvWeb.MemberExportController do
Member Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(select_fields) |> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union) |> load_custom_field_values_query(parsed.custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups) |> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type) |> maybe_load_membership_fee_type(need_membership_fee_type)

View file

@ -119,6 +119,78 @@ defmodule MvWeb.MemberExportControllerTest do
assert body =~ "Carol" assert body =~ "Carol"
end end
test "selected_ids override filters: only selected members exported when filters also set", %{
conn: conn,
member1: m1,
member2: m2,
member3: _m3
} do
# When selected_ids is set, cycle_status_filter and boolean_filters must not reduce the set:
# only the selected members are exported.
payload = %{
"selected_ids" => [m1.id, m2.id],
"member_fields" => ["first_name", "last_name", "email"],
"custom_field_ids" => [],
"query" => nil,
"sort_field" => nil,
"sort_order" => nil,
"cycle_status_filter" => "paid",
"boolean_filters" => %{}
}
conn = get(conn, "/members")
csrf_token = csrf_token_from_conn(conn)
conn =
post(conn, "/members/export.csv", %{
"payload" => Jason.encode!(payload),
"_csrf_token" => csrf_token
})
assert conn.status == 200
body = response(conn, 200)
lines = export_lines(body)
assert length(lines) == 3
assert body =~ "Alice"
assert body =~ "Bob"
refute body =~ "Carol"
end
test "cycle_status_filter applied when export all returns CSV", %{
conn: conn,
member1: _m1,
member2: _m2,
member3: _m3
} do
payload = %{
"selected_ids" => [],
"member_fields" => ["first_name", "email"],
"custom_field_ids" => [],
"query" => nil,
"sort_field" => nil,
"sort_order" => nil,
"cycle_status_filter" => "paid",
"show_current_cycle" => true
}
conn = get(conn, "/members")
csrf_token = csrf_token_from_conn(conn)
conn =
post(conn, "/members/export.csv", %{
"payload" => Jason.encode!(payload),
"_csrf_token" => csrf_token
})
assert conn.status == 200
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
body = response(conn, 200)
lines = export_lines(body)
assert length(lines) >= 1
assert hd(lines) =~ "First Name"
end
test "filters out unknown member fields from export", %{conn: conn, member1: m1} do test "filters out unknown member fields from export", %{conn: conn, member1: m1} do
payload = %{ payload = %{
"selected_ids" => [m1.id], "selected_ids" => [m1.id],
@ -607,6 +679,70 @@ defmodule MvWeb.MemberExportControllerTest do
refute body =~ member_with_integer.last_name refute body =~ member_with_integer.last_name
refute body =~ member_without_value.last_name refute body =~ member_without_value.last_name
end end
test "boolean_filters accept string true/false from query encoding", %{
conn: conn,
boolean_field: boolean_field,
member_with_boolean: member_with_boolean
} do
payload = %{
"selected_ids" => [],
"member_fields" => ["first_name", "last_name"],
"custom_field_ids" => [boolean_field.id],
"query" => nil,
"sort_field" => nil,
"sort_order" => nil,
"boolean_filters" => %{to_string(boolean_field.id) => "true"}
}
conn = get(conn, "/members")
csrf_token = csrf_token_from_conn(conn)
conn =
post(conn, "/members/export.csv", %{
"payload" => Jason.encode!(payload),
"_csrf_token" => csrf_token
})
assert conn.status == 200
body = response(conn, 200)
assert body =~ member_with_boolean.last_name
end
test "combination cycle_status_filter and boolean_filters applied when export all", %{
conn: conn,
boolean_field: boolean_field,
member_with_boolean: _member_with_boolean
} do
# Both filters are applied (AND). Export returns 200 and valid CSV.
payload = %{
"selected_ids" => [],
"member_fields" => ["first_name", "last_name"],
"custom_field_ids" => [boolean_field.id],
"query" => nil,
"sort_field" => nil,
"sort_order" => nil,
"cycle_status_filter" => "paid",
"show_current_cycle" => true,
"boolean_filters" => %{to_string(boolean_field.id) => true}
}
conn = get(conn, "/members")
csrf_token = csrf_token_from_conn(conn)
conn =
post(conn, "/members/export.csv", %{
"payload" => Jason.encode!(payload),
"_csrf_token" => csrf_token
})
assert conn.status == 200
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
body = response(conn, 200)
lines = export_lines(body)
assert length(lines) >= 1
assert hd(lines) =~ "First Name"
end
end end
describe "POST /members/export.pdf" do describe "POST /members/export.pdf" do