fix linting
This commit is contained in:
parent
80fe73a561
commit
e68a7cf8c7
4 changed files with 456 additions and 390 deletions
|
|
@ -55,14 +55,7 @@ defmodule Mv.Membership.MemberExport do
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
case Ash.read(query, actor: actor) do
|
||||||
{:ok, custom_fields} ->
|
{:ok, custom_fields} ->
|
||||||
by_id =
|
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
|
||||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
|
||||||
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)
|
|
||||||
|
|
||||||
{:ok, by_id}
|
{:ok, by_id}
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
|
@ -70,33 +63,72 @@ defmodule Mv.Membership.MemberExport do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_column_specs(parsed, custom_fields_by_id) do
|
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
||||||
member_specs =
|
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
||||||
Enum.map(parsed.member_fields, fn f ->
|
find_and_add_custom_field(acc, id, custom_fields)
|
||||||
if f in parsed.selectable_member_fields do
|
end)
|
||||||
%{kind: :member_field, key: f}
|
end
|
||||||
else
|
|
||||||
# only allow known computed export fields to avoid crashing on unknown atoms
|
|
||||||
if f in @computed_export_fields do
|
|
||||||
%{kind: :computed, key: String.to_existing_atom(f)}
|
|
||||||
else
|
|
||||||
# ignore unknown non-selectable fields defensively
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|
|
||||||
custom_specs =
|
defp find_and_add_custom_field(acc, id, custom_fields) do
|
||||||
parsed.custom_field_ids
|
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
||||||
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|
nil -> acc
|
||||||
|> Enum.reject(&is_nil/1)
|
cf -> Map.put(acc, id, cf)
|
||||||
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
|
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
|
member_specs ++ custom_specs
|
||||||
end
|
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
|
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 =
|
select_fields =
|
||||||
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
||||||
|
|
||||||
|
|
@ -114,51 +146,48 @@ defmodule Mv.Membership.MemberExport do
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|> load_custom_field_values_query(custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|
|
||||||
query =
|
if parsed.selected_ids != [] do
|
||||||
if parsed.selected_ids != [] do
|
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
||||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
else
|
||||||
else
|
query
|
||||||
query
|
|> apply_search(parsed.query)
|
||||||
|> apply_search(parsed.query)
|
|> then(fn q ->
|
||||||
|> then(fn q ->
|
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
|
||||||
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
|
q
|
||||||
q
|
end)
|
||||||
end)
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
defp process_loaded_members(members, parsed, custom_fields_by_id) do
|
||||||
{:ok, members} ->
|
members
|
||||||
members =
|
|> apply_post_load_filters(parsed, custom_fields_by_id)
|
||||||
if parsed.selected_ids == [] do
|
|> apply_post_load_sorting(parsed, custom_fields_by_id)
|
||||||
members
|
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
|
||||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
end
|
||||||
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
|
||||||
parsed.boolean_filters || %{},
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
|
|
||||||
members =
|
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
|
||||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
if parsed.selected_ids == [] do
|
||||||
sort_members_by_custom_field(
|
members
|
||||||
members,
|
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||||
parsed.sort_field,
|
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
parsed.sort_order,
|
parsed.boolean_filters || %{},
|
||||||
Map.values(custom_fields_by_id)
|
Map.values(custom_fields_by_id)
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
members
|
members
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Calculate membership_fee_status for computed fields
|
defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
|
||||||
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
|
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
||||||
|
sort_members_by_custom_field(
|
||||||
{:ok, members}
|
members,
|
||||||
|
parsed.sort_field,
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
parsed.sort_order,
|
||||||
{:error, :forbidden}
|
Map.values(custom_fields_by_id)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
members
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -259,7 +288,8 @@ defmodule Mv.Membership.MemberExport do
|
||||||
if "membership_fee_status" in computed_fields do
|
if "membership_fee_status" in computed_fields do
|
||||||
Enum.map(members, fn member ->
|
Enum.map(members, fn member ->
|
||||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
|
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
|
||||||
Map.put(member, :membership_fee_status, status) # <= Atom rein
|
# <= Atom rein
|
||||||
|
Map.put(member, :membership_fee_status, status)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
members
|
members
|
||||||
|
|
@ -333,8 +363,9 @@ defmodule Mv.Membership.MemberExport 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} -> is_binary(k) and is_boolean(v) end)
|
|> Enum.filter(fn {k, v} ->
|
||||||
|> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end)
|
is_binary(k) and is_boolean(v) and match?({:ok, _}, Ecto.UUID.cast(k))
|
||||||
|
end)
|
||||||
|> Enum.into(%{})
|
|> Enum.into(%{})
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
|
||||||
|
|
@ -96,18 +96,21 @@ defmodule MvWeb.MemberExportController do
|
||||||
|
|
||||||
defp filter_existing_atoms(list) when is_list(list) do
|
defp filter_existing_atoms(list) when is_list(list) do
|
||||||
list
|
list
|
||||||
|> Enum.filter(&is_binary/1)
|
|
||||||
|> Enum.filter(fn name ->
|
|> Enum.filter(fn name ->
|
||||||
try do
|
is_binary(name) and atom_exists?(name)
|
||||||
_ = String.to_existing_atom(name)
|
|
||||||
true
|
|
||||||
rescue
|
|
||||||
ArgumentError -> false
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp atom_exists?(name) do
|
||||||
|
try do
|
||||||
|
_ = String.to_existing_atom(name)
|
||||||
|
true
|
||||||
|
rescue
|
||||||
|
ArgumentError -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp extract_list(params, key) do
|
defp extract_list(params, key) do
|
||||||
case Map.get(params, key) do
|
case Map.get(params, key) do
|
||||||
list when is_list(list) -> list
|
list when is_list(list) -> list
|
||||||
|
|
@ -215,13 +218,17 @@ defmodule MvWeb.MemberExportController do
|
||||||
|
|
||||||
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
||||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
||||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
find_and_add_custom_field(acc, id, custom_fields)
|
||||||
nil -> acc
|
|
||||||
cf -> Map.put(acc, id, cf)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
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
|
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)
|
||||||
|
|
||||||
|
|
@ -322,19 +329,17 @@ defmodule MvWeb.MemberExportController do
|
||||||
defp maybe_sort_export(query, _field, nil), 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
|
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
||||||
cond do
|
if custom_field_sort?(field) do
|
||||||
custom_field_sort?(field) ->
|
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
||||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
{query, true}
|
||||||
{query, true}
|
else
|
||||||
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
true ->
|
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||||
field_atom = String.to_existing_atom(field)
|
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||||
|
else
|
||||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
{query, false}
|
||||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
end
|
||||||
else
|
|
||||||
{query, false}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
ArgumentError -> {query, false}
|
ArgumentError -> {query, false}
|
||||||
|
|
@ -372,12 +377,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
|
|
||||||
sorted_with_values =
|
sorted_with_values =
|
||||||
Enum.sort_by(with_values, fn member ->
|
Enum.sort_by(with_values, fn member ->
|
||||||
member
|
extract_member_sort_value(member, custom_field)
|
||||||
|> find_cfv(custom_field)
|
|
||||||
|> case do
|
|
||||||
nil -> nil
|
|
||||||
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
sorted_with_values =
|
sorted_with_values =
|
||||||
|
|
@ -415,6 +415,13 @@ defmodule MvWeb.MemberExportController do
|
||||||
end)
|
end)
|
||||||
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
|
defp build_columns(conn, parsed, custom_fields_by_id) do
|
||||||
member_cols =
|
member_cols =
|
||||||
Enum.map(parsed.selectable_member_fields, fn field ->
|
Enum.map(parsed.selectable_member_fields, fn field ->
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
new_show_current,
|
new_show_current,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
||||||
|
|
@ -352,7 +355,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||||
end
|
end
|
||||||
|
|
@ -374,7 +380,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
||||||
|
|
@ -398,7 +407,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||||
|
|
@ -428,7 +440,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
updated_filters
|
updated_filters
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||||
|
|
@ -452,7 +467,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
boolean_filters
|
boolean_filters
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||||
|
|
@ -542,11 +560,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _url, socket) do
|
def handle_params(params, _url, socket) do
|
||||||
prev_sig = build_signature(socket)
|
prev_sig = build_signature(socket)
|
||||||
|
|
||||||
fields_in_url? =
|
fields_in_url? =
|
||||||
case Map.get(params, "fields") do
|
case Map.get(params, "fields") do
|
||||||
v when is_binary(v) and v != "" -> true
|
v when is_binary(v) and v != "" -> true
|
||||||
_ -> false
|
_ -> false
|
||||||
end
|
end
|
||||||
|
|
||||||
url_selection = FieldSelection.parse_from_url(params)
|
url_selection = FieldSelection.parse_from_url(params)
|
||||||
|
|
||||||
|
|
@ -691,9 +710,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
|
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
|
||||||
fields_param = FieldSelection.to_url_param(selection)
|
fields_param = FieldSelection.to_url_param(selection)
|
||||||
|
|
||||||
cond do
|
if fields_param == "" do
|
||||||
fields_param == "" -> Map.delete(params, "fields")
|
Map.delete(params, "fields")
|
||||||
true -> Map.put(params, "fields", fields_param)
|
else
|
||||||
|
Map.put(params, "fields", fields_param)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -900,15 +920,23 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
||||||
|
|
||||||
defp maybe_sort(query, field, order, _custom_fields) do
|
defp maybe_sort(query, field, order, _custom_fields) do
|
||||||
|
if computed_field?(field) do
|
||||||
|
{query, false}
|
||||||
|
else
|
||||||
|
apply_sort_to_query(query, field, order)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp computed_field?(field) do
|
||||||
computed_atoms = FieldVisibility.computed_member_fields()
|
computed_atoms = FieldVisibility.computed_member_fields()
|
||||||
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
|
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
|
||||||
|
|
||||||
cond do
|
(is_atom(field) and field in computed_atoms) or
|
||||||
# Block computed fields (atom and string variants)
|
(is_binary(field) and field in computed_strings)
|
||||||
(is_atom(field) and field in computed_atoms) or
|
end
|
||||||
(is_binary(field) and field in computed_strings) ->
|
|
||||||
{query, false}
|
|
||||||
|
|
||||||
|
defp apply_sort_to_query(query, field, order) do
|
||||||
|
cond do
|
||||||
# Custom field sort -> after load
|
# Custom field sort -> after load
|
||||||
custom_field_sort?(field) ->
|
custom_field_sort?(field) ->
|
||||||
{query, true}
|
{query, true}
|
||||||
|
|
|
||||||
|
|
@ -211,294 +211,294 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
assert header =~ "Membership Fee Status"
|
assert header =~ "Membership Fee Status"
|
||||||
end
|
end
|
||||||
|
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create custom fields for different types
|
# Create custom fields for different types
|
||||||
{:ok, string_field} =
|
{:ok, string_field} =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Phone Number",
|
name: "Phone Number",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, integer_field} =
|
{:ok, integer_field} =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Membership Number",
|
name: "Membership Number",
|
||||||
value_type: :integer
|
value_type: :integer
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, boolean_field} =
|
{:ok, boolean_field} =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Active Member",
|
name: "Active Member",
|
||||||
value_type: :boolean
|
value_type: :boolean
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create members with custom field values
|
# Create members with custom field values
|
||||||
{:ok, member_with_string} =
|
{:ok, member_with_string} =
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "String",
|
last_name: "String",
|
||||||
email: "test.string@example.com"
|
email: "test.string@example.com"
|
||||||
},
|
},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv_string} =
|
{:ok, _cfv_string} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_with_string.id,
|
member_id: member_with_string.id,
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: "+49 123 456789"
|
value: "+49 123 456789"
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_integer} =
|
{:ok, member_with_integer} =
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Integer",
|
last_name: "Integer",
|
||||||
email: "test.integer@example.com"
|
email: "test.integer@example.com"
|
||||||
},
|
},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv_integer} =
|
{:ok, _cfv_integer} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_with_integer.id,
|
member_id: member_with_integer.id,
|
||||||
custom_field_id: integer_field.id,
|
custom_field_id: integer_field.id,
|
||||||
value: 12345
|
value: 12_345
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_boolean} =
|
{:ok, member_with_boolean} =
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Boolean",
|
last_name: "Boolean",
|
||||||
email: "test.boolean@example.com"
|
email: "test.boolean@example.com"
|
||||||
},
|
},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv_boolean} =
|
{:ok, _cfv_boolean} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_with_boolean.id,
|
member_id: member_with_boolean.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: true
|
value: true
|
||||||
})
|
})
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_without_value} =
|
{:ok, member_without_value} =
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "NoValue",
|
last_name: "NoValue",
|
||||||
email: "test.novalue@example.com"
|
email: "test.novalue@example.com"
|
||||||
},
|
},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
conn: conn,
|
|
||||||
string_field: string_field,
|
|
||||||
integer_field: integer_field,
|
|
||||||
boolean_field: boolean_field,
|
|
||||||
member_with_string: member_with_string,
|
|
||||||
member_with_integer: member_with_integer,
|
|
||||||
member_with_boolean: member_with_boolean,
|
|
||||||
member_without_value: member_without_value
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "export includes custom field column with string value", %{
|
|
||||||
conn: conn,
|
|
||||||
string_field: string_field,
|
|
||||||
member_with_string: member
|
|
||||||
} do
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [member.id],
|
|
||||||
"member_fields" => ["first_name", "last_name"],
|
|
||||||
"custom_field_ids" => [string_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = hd(lines)
|
|
||||||
|
|
||||||
assert header =~ "First Name"
|
|
||||||
assert header =~ "Last Name"
|
|
||||||
assert header =~ "Phone Number"
|
|
||||||
assert body =~ "Test"
|
|
||||||
assert body =~ "String"
|
|
||||||
assert body =~ "+49 123 456789"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "export includes custom field column with integer value", %{
|
|
||||||
conn: conn,
|
|
||||||
integer_field: integer_field,
|
|
||||||
member_with_integer: member
|
|
||||||
} do
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [member.id],
|
|
||||||
"member_fields" => ["first_name"],
|
|
||||||
"custom_field_ids" => [integer_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
header = body |> export_lines() |> hd()
|
|
||||||
|
|
||||||
assert header =~ "First Name"
|
|
||||||
assert header =~ "Membership Number"
|
|
||||||
assert body =~ "Test"
|
|
||||||
assert body =~ "12345"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "export includes custom field column with boolean value", %{
|
|
||||||
conn: conn,
|
|
||||||
boolean_field: boolean_field,
|
|
||||||
member_with_boolean: member
|
|
||||||
} do
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [member.id],
|
|
||||||
"member_fields" => ["first_name"],
|
|
||||||
"custom_field_ids" => [boolean_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
header = body |> export_lines() |> hd()
|
|
||||||
|
|
||||||
assert header =~ "First Name"
|
|
||||||
assert header =~ "Active Member"
|
|
||||||
assert body =~ "Test"
|
|
||||||
# Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter
|
|
||||||
assert body =~ "Yes"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "export shows empty cell for member without custom field value", %{
|
|
||||||
conn: conn,
|
|
||||||
string_field: string_field,
|
|
||||||
member_without_value: member
|
|
||||||
} do
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [member.id],
|
|
||||||
"member_fields" => ["first_name", "last_name"],
|
|
||||||
"custom_field_ids" => [string_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = hd(lines)
|
|
||||||
data_line = Enum.at(lines, 1)
|
|
||||||
|
|
||||||
assert header =~ "Phone Number"
|
|
||||||
# Empty custom field value should result in empty cell (two consecutive commas)
|
|
||||||
assert data_line =~ "Test,NoValue,"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "export includes multiple custom fields in correct order", %{
|
|
||||||
conn: conn,
|
conn: conn,
|
||||||
string_field: string_field,
|
string_field: string_field,
|
||||||
integer_field: integer_field,
|
integer_field: integer_field,
|
||||||
boolean_field: boolean_field,
|
boolean_field: boolean_field,
|
||||||
member_with_string: member
|
member_with_string: member_with_string,
|
||||||
} do
|
member_with_integer: member_with_integer,
|
||||||
payload = %{
|
member_with_boolean: member_with_boolean,
|
||||||
"selected_ids" => [member.id],
|
member_without_value: member_without_value
|
||||||
"member_fields" => ["first_name"],
|
}
|
||||||
"custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id],
|
end
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get(conn, "/members")
|
test "export includes custom field column with string value", %{
|
||||||
csrf_token = csrf_token_from_conn(conn)
|
conn: conn,
|
||||||
|
string_field: string_field,
|
||||||
|
member_with_string: member
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [member.id],
|
||||||
|
"member_fields" => ["first_name", "last_name"],
|
||||||
|
"custom_field_ids" => [string_field.id],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
conn =
|
conn = get(conn, "/members")
|
||||||
post(conn, "/members/export.csv", %{
|
csrf_token = csrf_token_from_conn(conn)
|
||||||
"payload" => Jason.encode!(payload),
|
|
||||||
"_csrf_token" => csrf_token
|
|
||||||
})
|
|
||||||
|
|
||||||
assert conn.status == 200
|
conn =
|
||||||
body = response(conn, 200)
|
post(conn, "/members/export.csv", %{
|
||||||
header = body |> export_lines() |> hd()
|
"payload" => Jason.encode!(payload),
|
||||||
|
"_csrf_token" => csrf_token
|
||||||
|
})
|
||||||
|
|
||||||
assert header =~ "First Name"
|
assert conn.status == 200
|
||||||
assert header =~ "Phone Number"
|
body = response(conn, 200)
|
||||||
assert header =~ "Membership Number"
|
lines = export_lines(body)
|
||||||
assert header =~ "Active Member"
|
header = hd(lines)
|
||||||
# Verify order: member fields first, then custom fields in the order specified
|
|
||||||
header_parts = String.split(header, ",")
|
|
||||||
first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name"))
|
|
||||||
phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number"))
|
|
||||||
membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number"))
|
|
||||||
active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member"))
|
|
||||||
|
|
||||||
assert first_name_idx < phone_idx
|
assert header =~ "First Name"
|
||||||
assert phone_idx < membership_idx
|
assert header =~ "Last Name"
|
||||||
assert membership_idx < active_idx
|
assert header =~ "Phone Number"
|
||||||
end
|
assert body =~ "Test"
|
||||||
|
assert body =~ "String"
|
||||||
|
assert body =~ "+49 123 456789"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "export includes custom field column with integer value", %{
|
||||||
|
conn: conn,
|
||||||
|
integer_field: integer_field,
|
||||||
|
member_with_integer: member
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [member.id],
|
||||||
|
"member_fields" => ["first_name"],
|
||||||
|
"custom_field_ids" => [integer_field.id],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
|
assert header =~ "First Name"
|
||||||
|
assert header =~ "Membership Number"
|
||||||
|
assert body =~ "Test"
|
||||||
|
assert body =~ "12345"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "export includes custom field column with boolean value", %{
|
||||||
|
conn: conn,
|
||||||
|
boolean_field: boolean_field,
|
||||||
|
member_with_boolean: member
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [member.id],
|
||||||
|
"member_fields" => ["first_name"],
|
||||||
|
"custom_field_ids" => [boolean_field.id],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
|
assert header =~ "First Name"
|
||||||
|
assert header =~ "Active Member"
|
||||||
|
assert body =~ "Test"
|
||||||
|
# Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter
|
||||||
|
assert body =~ "Yes"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "export shows empty cell for member without custom field value", %{
|
||||||
|
conn: conn,
|
||||||
|
string_field: string_field,
|
||||||
|
member_without_value: member
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [member.id],
|
||||||
|
"member_fields" => ["first_name", "last_name"],
|
||||||
|
"custom_field_ids" => [string_field.id],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = hd(lines)
|
||||||
|
data_line = Enum.at(lines, 1)
|
||||||
|
|
||||||
|
assert header =~ "Phone Number"
|
||||||
|
# Empty custom field value should result in empty cell (two consecutive commas)
|
||||||
|
assert data_line =~ "Test,NoValue,"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "export includes multiple custom fields in correct order", %{
|
||||||
|
conn: conn,
|
||||||
|
string_field: string_field,
|
||||||
|
integer_field: integer_field,
|
||||||
|
boolean_field: boolean_field,
|
||||||
|
member_with_string: member
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [member.id],
|
||||||
|
"member_fields" => ["first_name"],
|
||||||
|
"custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
|
assert header =~ "First Name"
|
||||||
|
assert header =~ "Phone Number"
|
||||||
|
assert header =~ "Membership Number"
|
||||||
|
assert header =~ "Active Member"
|
||||||
|
# Verify order: member fields first, then custom fields in the order specified
|
||||||
|
header_parts = String.split(header, ",")
|
||||||
|
first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name"))
|
||||||
|
phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number"))
|
||||||
|
membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number"))
|
||||||
|
active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member"))
|
||||||
|
|
||||||
|
assert first_name_idx < phone_idx
|
||||||
|
assert phone_idx < membership_idx
|
||||||
|
assert membership_idx < active_idx
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue