CSV export: apply cycle_status_filter and boolean_filters when exporting all
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Moritz 2026-03-04 20:12:45 +01:00
parent 9f169b9835
commit ad641ed49e
Signed by: moritz
GPG key ID: 1020A035E5DD0824
3 changed files with 110 additions and 4 deletions

View file

@ -378,6 +378,33 @@ defmodule Mv.Membership.MemberExport do
end
end
@doc """
Applies export filters (cycle status and boolean custom field filters) when exporting "all" (no selected_ids).
Used by the CSV export controller so that "Export (all)" with active filters exports only the filtered members,
matching PDF export behavior.
- `members` - Loaded members (must have cycle data loaded when cycle_status_filter is used).
- `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).
When `opts.selected_ids` is not empty, returns `members` unchanged. Otherwise applies
cycle status filter and boolean custom field filters.
"""
@spec apply_export_filters([struct()], map(), map()) :: [struct()]
def apply_export_filters(members, opts, custom_fields_by_id) do
if opts[:selected_ids] != [] do
members
else
members
|> apply_cycle_status_filter(opts[:cycle_status_filter], opts[:show_current_cycle])
|> Index.apply_boolean_custom_field_filters(
Map.get(opts, :boolean_filters, %{}),
Map.values(custom_fields_by_id)
)
end
end
defp extract_list(params, key) do
case Map.get(params, key) do
list when is_list(list) -> list

View file

@ -13,6 +13,7 @@ defmodule MvWeb.MemberExportController do
alias Mv.Authorization.Actor
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MemberExport
alias Mv.Membership.MembersCSV
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.Translations.MemberFields
@ -76,10 +77,34 @@ defmodule MvWeb.MemberExportController do
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
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),
boolean_filters: extract_boolean_filters(params)
}
end
defp extract_cycle_status_filter(params) do
case Map.get(params, "cycle_status_filter") do
"paid" -> :paid
"unpaid" -> :unpaid
_ -> nil
end
end
defp extract_boolean_filters(params) do
case Map.get(params, "boolean_filters") do
map when is_map(map) ->
map
|> Enum.filter(fn {k, v} ->
is_binary(k) and is_boolean(v) and match?({:ok, _}, Ecto.UUID.cast(k))
end)
|> Enum.into(%{})
_ ->
%{}
end
end
defp split_member_fields(member_fields) do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
@ -156,7 +181,10 @@ defmodule MvWeb.MemberExportController do
parsed
|> ensure_sort_custom_field_loaded()
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
custom_field_ids_union =
(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
columns = build_columns(conn, parsed, custom_fields_by_id)
csv_iodata = MembersCSV.export(members, columns)
@ -232,8 +260,12 @@ defmodule MvWeb.MemberExportController 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)
custom_field_ids_union =
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
need_cycles =
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
(parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or
parsed.cycle_status_filter != nil
need_groups = "groups" in parsed.member_fields
@ -245,7 +277,7 @@ defmodule MvWeb.MemberExportController do
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
@ -276,6 +308,10 @@ defmodule MvWeb.MemberExportController do
members
end
# When exporting "all" (no selected_ids), apply same filters as PDF: cycle status and boolean custom fields
members =
MemberExport.apply_export_filters(members, parsed, custom_fields_by_id)
# Calculate membership_fee_status for computed fields
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)

View file

@ -564,6 +564,49 @@ defmodule MvWeb.MemberExportControllerTest do
assert phone_idx < membership_idx
assert membership_idx < active_idx
end
test "exports only filtered members when selected_ids empty and boolean_filters set (Export all)",
%{
conn: conn,
boolean_field: boolean_field,
member_with_boolean: member_with_boolean,
member_with_string: member_with_string,
member_with_integer: member_with_integer,
member_without_value: member_without_value
} do
# Simulate "filter + Export (all)": no selection, but boolean filter "Active Member = true"
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)
lines = export_lines(body)
# Header + data rows: only members matching the boolean filter (Active Member = true)
assert length(lines) >= 2
assert body =~ "Boolean"
assert body =~ member_with_boolean.last_name
# Other test members (no value or different value for that custom field) must not appear
refute body =~ member_with_string.last_name
refute body =~ member_with_integer.last_name
refute body =~ member_without_value.last_name
end
end
describe "POST /members/export.pdf" do