Mechanical cleanup, quick fixes & deduplication closes #531 #543

Merged
moritz merged 17 commits from issue/mitgliederverwaltung-531 into main 2026-06-16 16:06:52 +02:00
Showing only changes of commit c4a695329c - Show all commits

View file

@ -7,12 +7,6 @@ defmodule Mv.Membership.MemberExport do
and sends the download. and sends the download.
""" """
require Ash.Query
import Ash.Expr
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MemberExportSort
alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@ -35,261 +29,8 @@ defmodule Mv.Membership.MemberExport do
["membership_fee_type", "membership_fee_status", "groups"] ["membership_fee_type", "membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date" @computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) @domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@doc """
Fetches members and column specs for export.
- `actor` - Ash actor (e.g. current user)
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
the controller adds `:header` and optional computed columns to members before CSV export.
"""
@spec fetch(struct(), map()) ::
{:ok, [struct()], [map()]} | {:error, :forbidden}
def fetch(actor, parsed) do
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(actor, parsed, custom_fields_by_id) do
column_specs = build_column_specs(parsed, custom_fields_by_id)
{:ok, members, column_specs}
end
end
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
query =
CustomField
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|> Ash.Query.select([:id, :name, :value_type])
case Ash.read(query, actor: actor) do
{:ok, custom_fields} ->
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
{:ok, by_id}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
find_and_add_custom_field(acc, id, custom_fields)
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp build_column_specs(parsed, custom_fields_by_id) do
member_specs = build_member_column_specs(parsed)
custom_specs = build_custom_column_specs(parsed, custom_fields_by_id)
member_specs ++ custom_specs
end
defp build_member_column_specs(parsed) do
Enum.map(parsed.member_fields, fn f ->
build_single_member_spec(f, parsed.selectable_member_fields)
end)
|> Enum.reject(&is_nil/1)
end
defp build_single_member_spec(field, selectable_member_fields) do
if field in selectable_member_fields do
%{kind: :member_field, key: field}
else
build_computed_spec(field)
end
end
defp build_computed_spec(field) do
# only allow known computed export fields to avoid crashing on unknown atoms
if field in @computed_export_fields do
%{kind: :computed, key: String.to_existing_atom(field)}
else
# ignore unknown non-selectable fields defensively
nil
end
end
defp build_custom_column_specs(parsed, custom_fields_by_id) do
parsed.custom_field_ids
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|> Enum.reject(&is_nil/1)
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
end
defp load_members(actor, parsed, custom_fields_by_id) do
query = build_members_query(parsed, custom_fields_by_id)
case Ash.read(query, actor: actor) do
{:ok, members} ->
processed_members = process_loaded_members(members, parsed, custom_fields_by_id)
{:ok, processed_members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_members_query(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 || %{})
need_cycles =
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
if parsed.selected_ids != [] do
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search(parsed.query)
|> then(fn q ->
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
q
end)
end
end
defp process_loaded_members(members, parsed, custom_fields_by_id) do
members
|> apply_post_load_filters(parsed, custom_fields_by_id)
|> apply_post_load_sorting(parsed, custom_fields_by_id)
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
end
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
else
members
end
end
defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
sort_members_by_custom_field(
members,
parsed.sort_field,
parsed.sort_order,
Map.values(custom_fields_by_id)
)
else
members
end
end
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
cfv_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
Ash.Query.load(query, custom_field_values: cfv_query)
end
defp apply_search(query, nil), do: query
defp apply_search(query, ""), do: query
defp apply_search(query, q) when is_binary(q) do
if String.trim(q) != "" do
Member.fuzzy_search(query, %{query: q})
else
query
end
end
defp maybe_sort(query, nil, _order), do: {query, false}
defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
end
defp sort_after_load?(field) when is_binary(field),
do: String.starts_with?(field, @custom_field_prefix)
defp sort_after_load?(_), do: false
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
key_fn = fn member ->
cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
end
members
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|> Enum.map(fn {m, _} -> m end)
end
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
(Map.get(cfv, :custom_field) &&
to_string(cfv.custom_field.id) == to_string(custom_field.id))
end)
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
@ -298,20 +39,6 @@ defmodule Mv.Membership.MemberExport do
defp apply_cycle_status_filter(members, _status, _show_current), do: members defp apply_cycle_status_filter(members, _status, _show_current), do: members
defp add_computed_fields(members, computed_fields, show_current_cycle) do
computed_fields = computed_fields || []
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
# <= Atom rein
Map.put(member, :membership_fee_status, status)
end)
else
members
end
end
# Called by controller to build parsed map from raw params (kept here so controller stays thin) # Called by controller to build parsed map from raw params (kept here so controller stays thin)
@doc """ @doc """
Parses and validates export params (from JSON payload). Parses and validates export params (from JSON payload).