This commit is contained in:
parent
d34ff57531
commit
c82f4b7fd7
7 changed files with 487 additions and 1 deletions
291
lib/mv_web/controllers/member_export_controller.ex
Normal file
291
lib/mv_web/controllers/member_export_controller.ex
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
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.Membership.Member
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.MembersCSV
|
||||
alias Mv.Authorization.Actor
|
||||
|
||||
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
def export(conn, params) do
|
||||
actor = current_actor(conn)
|
||||
if is_nil(actor), do: return_forbidden(conn)
|
||||
|
||||
case params["payload"] do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "payload required"})
|
||||
|
||||
payload when is_binary(payload) ->
|
||||
case Jason.decode(payload) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
parsed = parse_and_validate(decoded)
|
||||
run_export(conn, actor, parsed)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "invalid JSON"})
|
||||
end
|
||||
end
|
||||
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
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
|
||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||
query: extract_string(params, "query"),
|
||||
sort_field: extract_string(params, "sort_field"),
|
||||
sort_order: extract_sort_order(params)
|
||||
}
|
||||
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
|
||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
|
||||
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
|
||||
csv_iodata = MembersCSV.export(members, parsed.member_fields, custom_fields_by_id)
|
||||
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 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 load_members_for_export(actor, parsed, custom_fields_by_id) do
|
||||
select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(select_fields)
|
||||
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
||||
else
|
||||
query
|
||||
|> apply_search_export(parsed.query)
|
||||
|> then(fn q ->
|
||||
{q, _sort_after_load} = maybe_sort_export(q, parsed.sort_field, parsed.sort_order)
|
||||
q
|
||||
end)
|
||||
end
|
||||
|
||||
case Ash.read(query, actor: actor) do
|
||||
{:ok, members} ->
|
||||
members =
|
||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
||||
sort_members_by_custom_field_export(
|
||||
members,
|
||||
parsed.sort_field,
|
||||
parsed.sort_order,
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
# selected_ids != []: no sort. selected_ids == [] and DB sort: already in query.
|
||||
members
|
||||
end
|
||||
|
||||
{:ok, members}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:error, :forbidden}
|
||||
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_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
|
||||
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_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
|
||||
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
|
||||
|
||||
extract_sort_val = fn member ->
|
||||
cfv = find_cfv(member, custom_field)
|
||||
if cfv, do: extract_sort_value(cfv.value, custom_field.value_type), else: nil
|
||||
end
|
||||
|
||||
sorted =
|
||||
members
|
||||
|> Enum.sort_by(extract_sort_val, fn
|
||||
nil, _ -> false
|
||||
_, nil -> true
|
||||
a, b -> if order == "desc", do: a >= b, else: a <= b
|
||||
end)
|
||||
|
||||
if order == "desc", do: Enum.reverse(sorted), else: sorted
|
||||
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 extract_sort_value(%Ash.Union{value: value, type: type}, _),
|
||||
do: extract_sort_value(value, type)
|
||||
|
||||
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)
|
||||
|
||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue