The nil-actor guard used a one-armed if and continued into the export path regardless. The CheckPagePermission plug already halts unauthenticated requests before this controller runs, so the corrected early return preserves observable behavior while removing the dead fall-through. The export action is split into per-payload clauses so the guard reads as a flat early return.
684 lines
21 KiB
Elixir
684 lines
21 KiB
Elixir
defmodule MvWeb.MemberExportController do
|
|
@moduledoc """
|
|
Controller for CSV export of members.
|
|
|
|
POST /members/export.csv with form param "payload" (JSON string).
|
|
Same permission and actor context as the member overview; 403 if unauthorized.
|
|
"""
|
|
use MvWeb, :controller
|
|
|
|
require Ash.Query
|
|
import Ash.Expr
|
|
|
|
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
|
|
use Gettext, backend: MvWeb.Gettext
|
|
|
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
|
["membership_fee_type", "groups"]
|
|
@computed_export_fields ["membership_fee_status"]
|
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
|
|
|
def export(conn, params) do
|
|
case current_actor(conn) do
|
|
nil -> return_forbidden(conn)
|
|
actor -> export_with_actor(conn, actor, params["payload"])
|
|
end
|
|
end
|
|
|
|
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
|
|
case Jason.decode(payload) do
|
|
{:ok, decoded} when is_map(decoded) ->
|
|
run_export(conn, actor, parse_and_validate(decoded))
|
|
|
|
_ ->
|
|
json_error(conn, "invalid JSON")
|
|
end
|
|
end
|
|
|
|
defp export_with_actor(conn, _actor, _payload) do
|
|
json_error(conn, "payload required")
|
|
end
|
|
|
|
defp json_error(conn, message) do
|
|
conn
|
|
|> put_status(400)
|
|
|> put_resp_content_type("application/json")
|
|
|> json(%{error: message})
|
|
end
|
|
|
|
defp current_actor(conn) do
|
|
conn.assigns[:current_user]
|
|
|> Actor.ensure_loaded()
|
|
end
|
|
|
|
defp return_forbidden(conn) do
|
|
conn
|
|
|> put_status(403)
|
|
|> put_resp_content_type("application/json")
|
|
|> json(%{error: "Forbidden"})
|
|
|> halt()
|
|
end
|
|
|
|
defp parse_and_validate(params) do
|
|
member_fields = filter_allowed_member_fields(extract_list(params, "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")),
|
|
member_fields: member_fields,
|
|
selectable_member_fields: selectable_member_fields,
|
|
computed_fields:
|
|
computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
|
|
custom_field_ids: custom_field_ids,
|
|
custom_field_ids_union: custom_field_ids_union,
|
|
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"),
|
|
cycle_status_filter: extract_cycle_status_filter(params),
|
|
boolean_filters: boolean_filters
|
|
}
|
|
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
|
|
case Map.get(params, "cycle_status_filter") do
|
|
"paid" -> :paid
|
|
"unpaid" -> :unpaid
|
|
_ -> nil
|
|
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
|
|
case Map.get(params, "boolean_filters") do
|
|
map when is_map(map) ->
|
|
map
|
|
|> Enum.filter(fn {k, v} ->
|
|
is_binary(k) and match?({:ok, _}, Ecto.UUID.cast(k)) and boolean_value?(v)
|
|
end)
|
|
|> Enum.map(fn {k, v} -> {k, normalize_boolean_value(v)} end)
|
|
|> Enum.into(%{})
|
|
|
|
_ ->
|
|
%{}
|
|
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
|
|
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
|
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
|
# "groups" is neither a domain field nor a computed field, it's handled separately
|
|
{selectable, computed}
|
|
end
|
|
|
|
defp extract_boolean(params, key) do
|
|
case Map.get(params, key) do
|
|
true -> true
|
|
"true" -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp filter_existing_atoms(list) when is_list(list) do
|
|
list
|
|
|> Enum.filter(fn name ->
|
|
is_binary(name) and atom_exists?(name)
|
|
end)
|
|
|> Enum.uniq()
|
|
end
|
|
|
|
defp atom_exists?(name) do
|
|
_ = String.to_existing_atom(name)
|
|
true
|
|
rescue
|
|
ArgumentError -> false
|
|
end
|
|
|
|
defp extract_list(params, key) do
|
|
case Map.get(params, key) do
|
|
list when is_list(list) -> list
|
|
_ -> []
|
|
end
|
|
end
|
|
|
|
defp extract_string(params, key) do
|
|
case Map.get(params, key) do
|
|
s when is_binary(s) -> s
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_sort_order(params) do
|
|
case Map.get(params, "sort_order") do
|
|
"asc" -> "asc"
|
|
"desc" -> "desc"
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp filter_allowed_member_fields(field_list) do
|
|
allowlist = MapSet.new(@member_fields_allowlist)
|
|
|
|
field_list
|
|
|> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
|
|
|> Enum.uniq()
|
|
end
|
|
|
|
defp filter_valid_uuids(id_list) when is_list(id_list) do
|
|
id_list
|
|
|> Enum.filter(fn id ->
|
|
is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
|
|
end)
|
|
|> Enum.uniq()
|
|
end
|
|
|
|
defp run_export(conn, actor, parsed) do
|
|
# FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
|
|
# auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
|
|
parsed =
|
|
parsed
|
|
|> ensure_sort_custom_field_loaded()
|
|
|
|
with {:ok, custom_fields_by_id} <-
|
|
load_custom_fields_by_id(parsed.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)
|
|
filename = "members-#{Date.utc_today()}.csv"
|
|
|
|
send_download(
|
|
conn,
|
|
{:binary, IO.iodata_to_binary(csv_iodata)},
|
|
filename: filename,
|
|
content_type: "text/csv; charset=utf-8"
|
|
)
|
|
else
|
|
{:error, :forbidden} ->
|
|
return_forbidden(conn)
|
|
end
|
|
end
|
|
|
|
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
|
|
nil ->
|
|
parsed
|
|
|
|
id ->
|
|
%{
|
|
parsed
|
|
| custom_field_ids: Enum.uniq([id | ids]),
|
|
custom_field_ids_union: Enum.uniq([id | union])
|
|
}
|
|
end
|
|
end
|
|
|
|
defp extract_sort_custom_field_id(field) when is_binary(field) do
|
|
if String.starts_with?(field, @custom_field_prefix) do
|
|
String.trim_leading(field, @custom_field_prefix)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp extract_sort_custom_field_id(_), do: nil
|
|
|
|
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])
|
|
|
|
query
|
|
|> Ash.read(actor: actor)
|
|
|> handle_custom_fields_read_result(custom_field_ids)
|
|
end
|
|
|
|
defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do
|
|
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
|
|
{:ok, by_id}
|
|
end
|
|
|
|
defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do
|
|
{:error, :forbidden}
|
|
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 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)
|
|
|
|
need_cycles =
|
|
(parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or
|
|
parsed.cycle_status_filter != nil
|
|
|
|
need_groups = "groups" in parsed.member_fields
|
|
|
|
need_membership_fee_type =
|
|
"membership_fee_type" in parsed.member_fields or
|
|
parsed.sort_field == "membership_fee_type"
|
|
|
|
query =
|
|
Member
|
|
|> Ash.Query.new()
|
|
|> Ash.Query.select(select_fields)
|
|
|> load_custom_field_values_query(parsed.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)
|
|
|
|
query =
|
|
if parsed.selected_ids != [] do
|
|
# selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
|
|
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
|
else
|
|
query
|
|
|> apply_search_export(parsed.query)
|
|
end
|
|
|
|
# FIX: Sortierung IMMER anwenden (auch bei selected_ids)
|
|
{query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
|
|
|
|
case Ash.read(query, actor: actor) do
|
|
{:ok, members} ->
|
|
members =
|
|
if sort_after_load do
|
|
sort_members_by_custom_field_export(
|
|
members,
|
|
parsed.sort_field,
|
|
parsed.sort_order,
|
|
Map.values(custom_fields_by_id)
|
|
)
|
|
else
|
|
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)
|
|
|
|
{:ok, members}
|
|
|
|
{:error, %Ash.Error.Forbidden{}} ->
|
|
{:error, :forbidden}
|
|
end
|
|
end
|
|
|
|
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 maybe_load_groups(query, false), do: query
|
|
|
|
defp maybe_load_groups(query, true) do
|
|
# Load groups with id and name only (for export formatting)
|
|
Ash.Query.load(query, groups: [:id, :name])
|
|
end
|
|
|
|
defp maybe_load_membership_fee_type(query, false), do: query
|
|
|
|
defp maybe_load_membership_fee_type(query, true) do
|
|
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
|
end
|
|
|
|
# Adds computed field values to members (e.g. membership_fee_status)
|
|
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
|
if "membership_fee_status" in computed_fields do
|
|
Enum.map(members, fn member ->
|
|
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
|
|
status_string = format_membership_fee_status(status)
|
|
Map.put(member, :membership_fee_status, status_string)
|
|
end)
|
|
else
|
|
members
|
|
end
|
|
end
|
|
|
|
# Formats membership fee status as German string
|
|
defp format_membership_fee_status(:paid), do: gettext("paid")
|
|
defp format_membership_fee_status(:unpaid), do: gettext("unpaid")
|
|
defp format_membership_fee_status(:suspended), do: gettext("suspended")
|
|
defp format_membership_fee_status(nil), do: ""
|
|
|
|
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_export(query, nil), do: query
|
|
defp apply_search_export(query, ""), do: query
|
|
|
|
defp apply_search_export(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_export(query, nil, _order), do: {query, false}
|
|
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
|
|
|
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
|
cond do
|
|
field == "groups" ->
|
|
{query, true}
|
|
|
|
field == "membership_fee_type" ->
|
|
apply_membership_fee_type_sort_export(query, order)
|
|
|
|
custom_field_sort?(field) ->
|
|
{query, true}
|
|
|
|
true ->
|
|
apply_member_field_sort_export(query, field, order)
|
|
end
|
|
rescue
|
|
ArgumentError -> {query, false}
|
|
end
|
|
|
|
defp apply_membership_fee_type_sort_export(query, order) do
|
|
order_atom = if order == "desc", do: :desc, else: :asc
|
|
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
|
end
|
|
|
|
defp apply_member_field_sort_export(query, field, order) do
|
|
field_atom = String.to_existing_atom(field)
|
|
|
|
sortable =
|
|
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
|
field_atom == :membership_fee_type
|
|
|
|
if sortable do
|
|
order_atom = if order == "desc", do: :desc, else: :asc
|
|
|
|
sort_field =
|
|
if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom
|
|
|
|
{Ash.Query.sort(query, [{sort_field, order_atom}]), false}
|
|
else
|
|
{query, false}
|
|
end
|
|
end
|
|
|
|
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Custom field sorting (match member table behavior)
|
|
# ------------------------------------------------------------------
|
|
|
|
defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
|
|
when members == [],
|
|
do: []
|
|
|
|
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
|
when is_binary(field) do
|
|
order = order || "asc"
|
|
|
|
if field == "groups" do
|
|
sort_members_by_groups_export(members, order)
|
|
else
|
|
sort_by_custom_field_value(members, field, order, custom_fields)
|
|
end
|
|
end
|
|
|
|
defp sort_by_custom_field_value(members, field, order, custom_fields) 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
|
|
else
|
|
# Match table:
|
|
# 1) values first, empty last
|
|
# 2) sort only values
|
|
# 3) for desc, reverse only the values-part
|
|
{with_values, without_values} =
|
|
Enum.split_with(members, fn member ->
|
|
has_non_empty_custom_field_value?(member, custom_field)
|
|
end)
|
|
|
|
sorted_with_values =
|
|
Enum.sort_by(with_values, fn member ->
|
|
extract_member_sort_value(member, custom_field)
|
|
end)
|
|
|
|
sorted_with_values =
|
|
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
|
|
|
|
sorted_with_values ++ without_values
|
|
end
|
|
end
|
|
|
|
defp sort_members_by_groups_export(members, order) do
|
|
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
|
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
|
first_group_name = fn member ->
|
|
(member.groups || [])
|
|
|> Enum.map(& &1.name)
|
|
|> Enum.min(fn -> nil end)
|
|
end
|
|
|
|
members
|
|
|> Enum.sort_by(fn member ->
|
|
name = first_group_name.(member)
|
|
# Nil (no groups) sorts last in asc, first in desc
|
|
{name == nil, name || ""}
|
|
end)
|
|
|> then(fn list ->
|
|
if order == "desc", do: Enum.reverse(list), else: list
|
|
end)
|
|
end
|
|
|
|
defp has_non_empty_custom_field_value?(member, custom_field) do
|
|
case find_cfv(member, custom_field) do
|
|
nil ->
|
|
false
|
|
|
|
cfv ->
|
|
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
|
not empty_value?(extracted, custom_field.value_type)
|
|
end
|
|
end
|
|
|
|
defp empty_value?(nil, _type), do: true
|
|
|
|
defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
|
|
String.trim(value) == ""
|
|
end
|
|
|
|
defp empty_value?(_value, _type), do: false
|
|
|
|
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 extract_member_sort_value(member, custom_field) do
|
|
case find_cfv(member, custom_field) do
|
|
nil -> nil
|
|
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
|
|
end
|
|
end
|
|
|
|
defp build_columns(conn, parsed, custom_fields_by_id) do
|
|
member_cols =
|
|
Enum.map(parsed.selectable_member_fields, fn field ->
|
|
%{
|
|
header: member_field_header(conn, field),
|
|
kind: :member_field,
|
|
key: field
|
|
}
|
|
end)
|
|
|
|
computed_cols =
|
|
Enum.map(parsed.computed_fields, fn key ->
|
|
%{
|
|
header: computed_field_header(conn, key),
|
|
kind: :computed,
|
|
key: String.to_existing_atom(key)
|
|
}
|
|
end)
|
|
|
|
membership_fee_type_col =
|
|
if "membership_fee_type" in parsed.member_fields do
|
|
[
|
|
%{
|
|
header: membership_fee_type_field_header(conn),
|
|
kind: :membership_fee_type,
|
|
key: :membership_fee_type
|
|
}
|
|
]
|
|
else
|
|
[]
|
|
end
|
|
|
|
groups_col =
|
|
if "groups" in parsed.member_fields do
|
|
[
|
|
%{
|
|
header: groups_field_header(conn),
|
|
kind: :groups,
|
|
key: :groups
|
|
}
|
|
]
|
|
else
|
|
[]
|
|
end
|
|
|
|
custom_cols =
|
|
parsed.custom_field_ids
|
|
|> Enum.map(fn id ->
|
|
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
|
|
|
|
if cf do
|
|
%{
|
|
header: custom_field_header(conn, cf),
|
|
kind: :custom_field,
|
|
key: to_string(id),
|
|
custom_field: cf
|
|
}
|
|
else
|
|
nil
|
|
end
|
|
end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
|
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
|
end
|
|
|
|
# --- headers: use MemberFields.label for translations ---
|
|
defp member_field_header(_conn, field) when is_binary(field) do
|
|
field
|
|
|> String.to_existing_atom()
|
|
|> MemberFields.label()
|
|
rescue
|
|
ArgumentError ->
|
|
# Fallback for unknown fields
|
|
humanize_field(field)
|
|
end
|
|
|
|
defp computed_field_header(_conn, key) when is_atom(key) do
|
|
# Map export-only alias to canonical UI key for translation
|
|
atom_key = if key == :payment_status, do: :membership_fee_status, else: key
|
|
MemberFields.label(atom_key)
|
|
end
|
|
|
|
defp computed_field_header(_conn, key) when is_binary(key) do
|
|
# Map export-only alias to canonical UI key for translation
|
|
atom_key =
|
|
case key do
|
|
"payment_status" -> :membership_fee_status
|
|
_ -> String.to_existing_atom(key)
|
|
end
|
|
|
|
MemberFields.label(atom_key)
|
|
rescue
|
|
ArgumentError ->
|
|
# Fallback for unknown computed fields
|
|
humanize_field(key)
|
|
end
|
|
|
|
defp custom_field_header(_conn, cf) do
|
|
# Custom fields: meist ist cf.name bereits der Display Name
|
|
cf.name
|
|
end
|
|
|
|
defp membership_fee_type_field_header(_conn) do
|
|
MemberFields.label(:membership_fee_type)
|
|
end
|
|
|
|
defp groups_field_header(_conn) do
|
|
MemberFields.label(:groups)
|
|
end
|
|
|
|
defp humanize_field(str) do
|
|
str
|
|
|> String.replace("_", " ")
|
|
|> String.split()
|
|
|> Enum.map_join(" ", &String.capitalize/1)
|
|
end
|
|
|
|
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
|
|
do: extract_sort_value(value, type)
|
|
|
|
defp extract_sort_value(nil, _), do: nil
|
|
defp extract_sort_value(value, :string) when is_binary(value), do: value
|
|
defp extract_sort_value(value, :integer) when is_integer(value), do: value
|
|
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
|
|
defp extract_sort_value(%Date{} = d, :date), do: d
|
|
defp extract_sort_value(value, :email) when is_binary(value), do: value
|
|
defp extract_sort_value(value, _), do: to_string(value)
|
|
end
|