feat: adds pdf export with imprintor
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
496e2e438f
commit
f6b35f03a5
16 changed files with 1962 additions and 70 deletions
|
|
@ -116,4 +116,30 @@ defmodule Mv.Config do
|
|||
defp parse_and_validate_integer(_value, default) do
|
||||
default
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of rows allowed in PDF exports.
|
||||
|
||||
Reads the `row_limit` value from the PDF export configuration.
|
||||
|
||||
## Returns
|
||||
|
||||
- Maximum number of rows (default: 5000)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Config.pdf_export_row_limit()
|
||||
5000
|
||||
"""
|
||||
@spec pdf_export_row_limit() :: pos_integer()
|
||||
def pdf_export_row_limit do
|
||||
get_pdf_export_config(:row_limit, 5000)
|
||||
end
|
||||
|
||||
# Helper function to get PDF export config values
|
||||
defp get_pdf_export_config(key, default) do
|
||||
Application.get_env(:mv, :pdf_export, [])
|
||||
|> Keyword.get(key, default)
|
||||
|> parse_and_validate_integer(default)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
433
lib/mv/membership/member_export/build.ex
Normal file
433
lib/mv/membership/member_export/build.ex
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
defmodule Mv.Membership.MemberExport.Build do
|
||||
@moduledoc """
|
||||
Builds export data structure for member exports (CSV/PDF).
|
||||
|
||||
Extracts common logic for loading, filtering, sorting, and formatting member data
|
||||
into a unified structure that can be used by both CSV and PDF exporters.
|
||||
|
||||
Returns a structure:
|
||||
```
|
||||
%{
|
||||
columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}],
|
||||
rows: [[cell_string, ...]],
|
||||
meta: %{generated_at: String.t(), member_count: integer(), ...}
|
||||
}
|
||||
```
|
||||
|
||||
No translations/Gettext in this module - labels come from the web layer via a function.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
@doc """
|
||||
Builds export data structure from parsed parameters.
|
||||
|
||||
- `actor` - Ash actor (e.g. current user)
|
||||
- `parsed` - Map with export parameters (from `MemberExport.parse_params/1`)
|
||||
- `label_fn` - Function to get labels for columns: `(key) -> String.t()`
|
||||
|
||||
Returns `{:ok, data}` or `{:error, :forbidden}`.
|
||||
|
||||
The `data` map contains:
|
||||
- `columns`: List of column specs with `key`, `kind`, and optional `custom_field`
|
||||
- `rows`: List of rows, each row is a list of cell strings
|
||||
- `meta`: Metadata including `generated_at` and `member_count`
|
||||
"""
|
||||
@spec build(struct(), map(), (term() -> String.t())) ::
|
||||
{:ok, map()} | {:error, :forbidden}
|
||||
def build(actor, parsed, label_fn) when is_function(label_fn, 1) do
|
||||
# Ensure sort custom field is loaded if needed
|
||||
parsed = ensure_sort_custom_field_loaded(parsed)
|
||||
|
||||
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
|
||||
columns = build_columns(parsed, custom_fields_by_id, label_fn)
|
||||
rows = build_rows(members, columns, custom_fields_by_id)
|
||||
meta = build_meta(members)
|
||||
|
||||
{:ok, %{columns: columns, rows: rows, meta: meta}}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, 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])}
|
||||
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])
|
||||
|
||||
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(actor, parsed, custom_fields_by_id) do
|
||||
{query, sort_after_load} = 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, sort_after_load)
|
||||
|
||||
{: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)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
||||
else
|
||||
apply_search(query, parsed.query)
|
||||
end
|
||||
|
||||
# Apply sorting at query level if possible (not custom fields)
|
||||
maybe_sort(query, parsed.sort_field, parsed.sort_order)
|
||||
end
|
||||
|
||||
defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do
|
||||
members
|
||||
|> apply_post_load_filters(parsed, custom_fields_by_id)
|
||||
|> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load)
|
||||
|> 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)
|
||||
|> MvWeb.MemberLive.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, sort_after_load) do
|
||||
# Sort after load for custom fields (always, even with selected_ids)
|
||||
if sort_after_load do
|
||||
sort_members_by_custom_field(
|
||||
members,
|
||||
parsed.sort_field,
|
||||
parsed.sort_order,
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
# For selected_ids, we may need to apply sorting that wasn't done at query level
|
||||
if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do
|
||||
# Re-sort in memory to ensure consistent ordering
|
||||
sort_members_in_memory(members, parsed.sort_field, parsed.sort_order)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_members_in_memory(members, field, order) when is_binary(field) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
if field_atom in Mv.Constants.member_fields() do
|
||||
Enum.sort_by(members, fn member -> Map.get(member, field_atom) end, fn a, b ->
|
||||
case order do
|
||||
"asc" -> a <= b
|
||||
"desc" -> b <= a
|
||||
_ -> true
|
||||
end
|
||||
end)
|
||||
else
|
||||
members
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> members
|
||||
end
|
||||
|
||||
defp sort_members_in_memory(members, _field, _order), do: members
|
||||
|
||||
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, status, show_current) when status in [:paid, :unpaid] do
|
||||
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
|
||||
end
|
||||
|
||||
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)
|
||||
# Format as string for export (controller will handle translation)
|
||||
status_string = format_membership_fee_status(status)
|
||||
Map.put(member, :membership_fee_status, status_string)
|
||||
end)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
defp format_membership_fee_status(:paid), do: "paid"
|
||||
defp format_membership_fee_status(:unpaid), do: "unpaid"
|
||||
defp format_membership_fee_status(:suspended), do: "suspended"
|
||||
defp format_membership_fee_status(nil), do: ""
|
||||
|
||||
defp build_columns(parsed, custom_fields_by_id, label_fn) do
|
||||
member_cols =
|
||||
Enum.map(parsed.selectable_member_fields, fn field ->
|
||||
%{
|
||||
key: field,
|
||||
kind: :member_field,
|
||||
label: label_fn.(field)
|
||||
}
|
||||
end)
|
||||
|
||||
computed_cols =
|
||||
Enum.map(parsed.computed_fields, fn key ->
|
||||
atom_key = String.to_existing_atom(key)
|
||||
|
||||
%{
|
||||
key: atom_key,
|
||||
kind: :computed,
|
||||
label: label_fn.(atom_key)
|
||||
}
|
||||
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
|
||||
%{
|
||||
key: to_string(id),
|
||||
kind: :custom_field,
|
||||
label: label_fn.(id),
|
||||
custom_field: cf
|
||||
}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
member_cols ++ computed_cols ++ custom_cols
|
||||
end
|
||||
|
||||
defp build_rows(members, columns, custom_fields_by_id) do
|
||||
Enum.map(members, fn member ->
|
||||
Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) do
|
||||
key_atom = key_to_atom(key)
|
||||
value = Map.get(member, key_atom)
|
||||
format_member_value(value)
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}, _custom_fields_by_id) do
|
||||
cfv = get_cfv_by_id(member, id)
|
||||
|
||||
if cfv do
|
||||
CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf)
|
||||
else
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do
|
||||
value = Map.get(member, key)
|
||||
if is_binary(value), do: value, else: ""
|
||||
end
|
||||
|
||||
defp key_to_atom(k) when is_atom(k), do: k
|
||||
|
||||
defp key_to_atom(k) when is_binary(k) do
|
||||
try do
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> k
|
||||
end
|
||||
end
|
||||
|
||||
defp get_cfv_by_id(member, id) do
|
||||
values =
|
||||
case Map.get(member, :custom_field_values) do
|
||||
v when is_list(v) -> v
|
||||
_ -> []
|
||||
end
|
||||
|
||||
id_str = to_string(id)
|
||||
|
||||
Enum.find(values, fn cfv ->
|
||||
to_string(cfv.custom_field_id) == id_str or
|
||||
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_member_value(nil), do: ""
|
||||
defp format_member_value(true), do: "true"
|
||||
defp format_member_value(false), do: "false"
|
||||
defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
|
||||
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp format_member_value(value), do: to_string(value)
|
||||
|
||||
defp build_meta(members) do
|
||||
%{
|
||||
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||
member_count: length(members)
|
||||
}
|
||||
end
|
||||
end
|
||||
270
lib/mv/membership/members_pdf.ex
Normal file
270
lib/mv/membership/members_pdf.ex
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
defmodule Mv.Membership.MembersPDF do
|
||||
@moduledoc """
|
||||
Exports members to PDF using Typst templates and Imprintor.
|
||||
|
||||
Uses the same data structure as `MemberExport.Build` and converts it
|
||||
to the format expected by the Typst template. No translations/Gettext
|
||||
in this module - labels come from the web layer.
|
||||
|
||||
Ensures deterministic output by maintaining column and row order.
|
||||
|
||||
Creates a temporary directory per request and copies the template there
|
||||
to avoid symlink issues and ensure isolation.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Config
|
||||
|
||||
@template_filename "members_export.typ"
|
||||
@template_path "priv/pdf_templates/members_export.typ"
|
||||
|
||||
@doc """
|
||||
Renders export data to PDF binary.
|
||||
|
||||
- `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
|
||||
|
||||
Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
|
||||
|
||||
The PDF binary starts with "%PDF" (PDF magic bytes).
|
||||
|
||||
Validates row count against configured limit before processing.
|
||||
"""
|
||||
@spec render(map()) :: {:ok, binary()} | {:error, term()}
|
||||
def render(export_data) do
|
||||
row_count = length(export_data.rows)
|
||||
max_rows = Config.pdf_export_row_limit()
|
||||
|
||||
if row_count > max_rows do
|
||||
Logger.warning("PDF export rejected: row count exceeds limit",
|
||||
row_count: row_count,
|
||||
max_rows: max_rows,
|
||||
error_type: :row_limit_exceeded
|
||||
)
|
||||
|
||||
{:error, {:row_limit_exceeded, row_count, max_rows}}
|
||||
else
|
||||
Logger.info("Starting PDF export",
|
||||
row_count: row_count,
|
||||
column_count: length(export_data.columns)
|
||||
)
|
||||
|
||||
create_and_use_temp_directory(export_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_and_use_temp_directory(export_data) do
|
||||
case create_temp_directory() do
|
||||
{:ok, temp_dir} ->
|
||||
try do
|
||||
with {:ok, template_content} <- load_template(),
|
||||
{:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
|
||||
{:ok, template_data} <- convert_to_template_format(export_data),
|
||||
{:ok, config} <-
|
||||
build_imprintor_config(template_content, template_data, temp_dir),
|
||||
{:ok, pdf_binary} <- compile_to_pdf(config) do
|
||||
Logger.info("PDF export completed successfully",
|
||||
row_count: length(export_data.rows)
|
||||
)
|
||||
|
||||
{:ok, pdf_binary}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("PDF export failed",
|
||||
error: reason,
|
||||
error_type: :pdf_export_failed
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
after
|
||||
cleanup_temp_directory(temp_dir)
|
||||
end
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to create temp directory",
|
||||
error: reason,
|
||||
error_type: :temp_dir_creation_failed
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_temp_directory do
|
||||
# Create unique temp directory per request
|
||||
temp_base = System.tmp_dir!()
|
||||
temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}")
|
||||
|
||||
case File.mkdir_p(temp_dir) do
|
||||
:ok -> {:ok, temp_dir}
|
||||
{:error, reason} -> {:error, {:temp_dir_creation_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_template do
|
||||
# Try multiple paths: compiled app path and source path (for tests/dev)
|
||||
template_paths = [
|
||||
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"),
|
||||
Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename])
|
||||
]
|
||||
|
||||
Enum.reduce_while(template_paths, nil, fn path, _acc ->
|
||||
case File.read(path) do
|
||||
{:ok, content} -> {:halt, {:ok, content}}
|
||||
{:error, _reason} -> {:cont, nil}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, content} -> {:ok, content}
|
||||
nil -> {:error, {:template_not_found, :enoent}}
|
||||
end
|
||||
end
|
||||
|
||||
defp copy_template_to_temp(temp_dir, template_content) do
|
||||
# Write template to temp directory (no symlinks, actual file copy)
|
||||
template_path = Path.join(temp_dir, @template_filename)
|
||||
|
||||
case File.write(template_path, template_content) do
|
||||
:ok -> {:ok, template_path}
|
||||
{:error, reason} -> {:error, {:template_copy_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_temp_directory(temp_dir) do
|
||||
# Clean up temp directory and all contents
|
||||
case File.rm_rf(temp_dir) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason, _} ->
|
||||
Logger.warning("Failed to cleanup temp directory",
|
||||
temp_dir: temp_dir,
|
||||
error: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp convert_to_template_format(export_data) do
|
||||
headers = Enum.map(export_data.columns, & &1.label)
|
||||
column_count = length(export_data.columns)
|
||||
|
||||
meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
|
||||
|
||||
generated_at =
|
||||
Map.get(meta, :generated_at) ||
|
||||
Map.get(meta, "generated_at") ||
|
||||
DateTime.utc_now() |> DateTime.to_iso8601()
|
||||
|
||||
member_count =
|
||||
Map.get(meta, :member_count) ||
|
||||
Map.get(meta, "member_count") ||
|
||||
length(export_data.rows)
|
||||
|
||||
template_data = %{
|
||||
"title" => "Mitglieder-Export",
|
||||
"generated_at" => generated_at,
|
||||
"column_count" => column_count,
|
||||
"headers" => headers,
|
||||
"rows" => export_data.rows,
|
||||
"columns" =>
|
||||
Enum.map(export_data.columns, fn col ->
|
||||
%{
|
||||
"key" => to_string(col.key),
|
||||
"kind" => to_string(col.kind),
|
||||
"label" => col.label
|
||||
}
|
||||
end),
|
||||
"meta" => %{
|
||||
"generated_at" => generated_at,
|
||||
"member_count" => member_count
|
||||
}
|
||||
}
|
||||
|
||||
{:ok, template_data}
|
||||
end
|
||||
|
||||
defp build_imprintor_config(template_content, template_data, root_directory) do
|
||||
# Imprintor.Config.new(source_document, inputs, options)
|
||||
# inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
|
||||
# options: set root_directory to temp dir to ensure no symlink issues
|
||||
# extra_fonts: list of font file paths for Typst to use
|
||||
extra_fonts = get_extra_fonts()
|
||||
options = [root_directory: root_directory, extra_fonts: extra_fonts]
|
||||
|
||||
config = Imprintor.Config.new(template_content, template_data, options)
|
||||
{:ok, config}
|
||||
end
|
||||
|
||||
defp get_extra_fonts do
|
||||
# Try multiple paths: compiled app path and source path (for tests/dev)
|
||||
font_paths = [
|
||||
Path.join(Application.app_dir(:mv, "priv"), "fonts"),
|
||||
Path.join([File.cwd!(), "priv", "fonts"])
|
||||
]
|
||||
|
||||
Enum.reduce_while(font_paths, [], fn base_path, _acc ->
|
||||
case File.ls(base_path) do
|
||||
{:ok, files} ->
|
||||
fonts =
|
||||
files
|
||||
|> Enum.filter(&String.ends_with?(&1, ".ttf"))
|
||||
|> Enum.map(&Path.join(base_path, &1))
|
||||
|> Enum.sort()
|
||||
|
||||
if fonts != [] do
|
||||
{:halt, fonts}
|
||||
else
|
||||
{:cont, []}
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
{:cont, []}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[] -> []
|
||||
fonts -> fonts
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_to_pdf(config) do
|
||||
case Imprintor.compile_to_pdf(config) do
|
||||
{:ok, pdf_binary} when is_binary(pdf_binary) ->
|
||||
# Verify PDF magic bytes
|
||||
if String.starts_with?(pdf_binary, "%PDF") do
|
||||
{:ok, pdf_binary}
|
||||
else
|
||||
Logger.error("PDF compilation returned invalid format",
|
||||
binary_start: String.slice(pdf_binary, 0, 20)
|
||||
)
|
||||
|
||||
{:error, :invalid_pdf_format}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("PDF compilation failed",
|
||||
error: inspect(reason),
|
||||
error_type: :imprintor_compile_error
|
||||
)
|
||||
|
||||
{:error, {:compile_error, reason}}
|
||||
|
||||
other ->
|
||||
Logger.error("PDF compilation returned unexpected result",
|
||||
result: inspect(other),
|
||||
error_type: :unexpected_result
|
||||
)
|
||||
|
||||
{:error, {:unexpected_result, other}}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("PDF compilation raised exception",
|
||||
exception: inspect(e),
|
||||
error_type: :compile_exception
|
||||
)
|
||||
|
||||
{:error, {:compile_exception, e}}
|
||||
end
|
||||
end
|
||||
|
|
@ -151,9 +151,17 @@ defmodule MvWeb.CoreComponents do
|
|||
## Examples
|
||||
|
||||
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
|
||||
|
||||
When using custom content (e.g., forms), use the inner_block slot:
|
||||
|
||||
<.dropdown_menu button_label="Export" icon="hero-arrow-down-tray" open={@open} phx_target={@myself}>
|
||||
<li role="none">
|
||||
<form>...</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
"""
|
||||
attr :id, :string, default: "dropdown-menu"
|
||||
attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
|
||||
attr :items, :list, default: [], doc: "List of %{label: string, value: any} maps"
|
||||
attr :button_label, :string, default: "Dropdown"
|
||||
attr :icon, :string, default: nil
|
||||
attr :checkboxes, :boolean, default: false
|
||||
|
|
@ -161,8 +169,20 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
|
||||
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
||||
attr :button_class, :string, default: nil, doc: "Additional CSS classes for the button (e.g., btn-secondary)"
|
||||
attr :menu_align, :string, default: "right", doc: "Menu alignment: 'left' or 'right' (default: right)"
|
||||
attr :testid, :string, default: "dropdown-menu", doc: "data-testid for the dropdown container"
|
||||
attr :button_testid, :string, default: "dropdown-button", doc: "data-testid for the button"
|
||||
attr :menu_testid, :string, default: nil, doc: "data-testid for the menu (defaults to testid + '-menu')"
|
||||
|
||||
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
assigns = assign(assigns, :menu_testid, menu_testid)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
|
|
@ -170,7 +190,7 @@ defmodule MvWeb.CoreComponents do
|
|||
phx-target={@phx_target}
|
||||
phx-window-keydown="close_dropdown"
|
||||
phx-key="Escape"
|
||||
data-testid="dropdown-menu"
|
||||
data-testid={@testid}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -180,10 +200,10 @@ defmodule MvWeb.CoreComponents do
|
|||
aria-expanded={@open}
|
||||
aria-controls={@id}
|
||||
aria-label={@button_label}
|
||||
class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
|
||||
class={["btn", "focus:outline-none", "focus-visible:ring-2", "focus-visible:ring-offset-2", "focus-visible:ring-base-content/20", @button_class]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid="dropdown-button"
|
||||
data-testid={@button_testid}
|
||||
>
|
||||
<%= if @icon do %>
|
||||
<.icon name={@icon} />
|
||||
|
|
@ -195,69 +215,79 @@ defmodule MvWeb.CoreComponents do
|
|||
:if={@open}
|
||||
id={@id}
|
||||
role="menu"
|
||||
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
|
||||
class={[
|
||||
"absolute mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box max-h-96 overflow-y-auto border border-base-300",
|
||||
if(@menu_align == "left", do: "left-0", else: "right-0"),
|
||||
@menu_width,
|
||||
@menu_class
|
||||
]}
|
||||
tabindex="0"
|
||||
phx-target={@phx_target}
|
||||
data-testid={@menu_testid}
|
||||
>
|
||||
<li :if={@show_select_buttons} role="none">
|
||||
<div class="flex justify-between items-center mb-2 px-2">
|
||||
<span class="font-semibold">{gettext("Options")}</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select all")}
|
||||
phx-click="select_all"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("All")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select none")}
|
||||
phx-click="select_none"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("None")}
|
||||
</button>
|
||||
<%= if assigns.inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
<% else %>
|
||||
<li :if={@show_select_buttons} role="none">
|
||||
<div class="flex justify-between items-center mb-2 px-2">
|
||||
<span class="font-semibold">{gettext("Options")}</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select all")}
|
||||
phx-click="select_all"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("All")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select none")}
|
||||
phx-click="select_none"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("None")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
|
||||
|
||||
<%= for item <- @items do %>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||
aria-label={item.label}
|
||||
aria-checked={
|
||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
phx-value-item={item.value}
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<%= if @checkboxes do %>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<% end %>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
|
||||
|
||||
<%= for item <- @items do %>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||
aria-label={item.label}
|
||||
aria-checked={
|
||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
phx-value-item={item.value}
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<%= if @checkboxes do %>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<% end %>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
97
lib/mv_web/components/export_dropdown.ex
Normal file
97
lib/mv_web/components/export_dropdown.ex
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
defmodule MvWeb.Components.ExportDropdown do
|
||||
@moduledoc """
|
||||
Export dropdown component for member export (CSV/PDF).
|
||||
|
||||
Provides an accessible dropdown menu with CSV and PDF export options.
|
||||
Uses the same export payload as the previous single-button export.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
button_label =
|
||||
gettext("Export") <>
|
||||
" (" <>
|
||||
(if assigns.selected_count == 0, do: gettext("all"), else: to_string(assigns.selected_count)) <>
|
||||
")"
|
||||
|
||||
assigns = assign(assigns, :button_label, button_label)
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="export-dropdown">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={@button_label}
|
||||
icon="hero-arrow-down-tray"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-48"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="export-dropdown"
|
||||
button_testid="export-dropdown-button"
|
||||
menu_testid="export-dropdown-menu"
|
||||
>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
144
lib/mv_web/controllers/member_pdf_export_controller.ex
Normal file
144
lib/mv_web/controllers/member_pdf_export_controller.ex
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
defmodule MvWeb.MemberPdfExportController do
|
||||
@moduledoc """
|
||||
PDF export for members.
|
||||
|
||||
Expects `payload` as JSON string form param.
|
||||
Uses the same actor/permissions as the member overview.
|
||||
"""
|
||||
|
||||
use MvWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@payload_required_message "payload required"
|
||||
@invalid_json_message "invalid JSON"
|
||||
@export_failed_message "Failed to generate PDF export"
|
||||
|
||||
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
||||
actor = current_actor(conn)
|
||||
|
||||
cond do
|
||||
is_nil(actor) ->
|
||||
forbidden(conn)
|
||||
|
||||
true ->
|
||||
with {:ok, decoded} <- decode_json_map(payload),
|
||||
parsed <- MemberExport.parse_params(decoded),
|
||||
{:ok, export_data} <- Build.build(actor, parsed, &label_for_column/1),
|
||||
{:ok, pdf_binary} <- MembersPDF.render(export_data) do
|
||||
filename = "members-#{Date.utc_today()}.pdf"
|
||||
|
||||
send_download(
|
||||
conn,
|
||||
{:binary, pdf_binary},
|
||||
filename: filename,
|
||||
content_type: "application/pdf"
|
||||
)
|
||||
else
|
||||
{:error, :invalid_json} ->
|
||||
bad_request(conn, @invalid_json_message)
|
||||
|
||||
{:error, :forbidden} ->
|
||||
forbidden(conn)
|
||||
|
||||
{:error, {:row_limit_exceeded, row_count, max_rows}} ->
|
||||
unprocessable_entity(conn, %{
|
||||
error: "row_limit_exceeded",
|
||||
message:
|
||||
gettext("Export contains %{count} rows, maximum is %{max}",
|
||||
count: row_count,
|
||||
max: max_rows
|
||||
),
|
||||
row_count: row_count,
|
||||
max_rows: max_rows
|
||||
})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("PDF export failed: #{inspect(reason)}")
|
||||
|
||||
internal_error(conn, %{
|
||||
error: "export_failed",
|
||||
message: gettext(@export_failed_message)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def export(conn, _params) do
|
||||
bad_request(conn, @payload_required_message)
|
||||
end
|
||||
|
||||
# --- Actor / auth ---
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp forbidden(conn) do
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "forbidden", message: "Forbidden"})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
# --- Decoding / validation ---
|
||||
|
||||
defp decode_json_map(payload) when is_binary(payload) do
|
||||
case Jason.decode(payload) do
|
||||
{:ok, decoded} when is_map(decoded) -> {:ok, decoded}
|
||||
_ -> {:error, :invalid_json}
|
||||
end
|
||||
end
|
||||
|
||||
# --- Column labels ---
|
||||
|
||||
# Goal: translate known member fields to UI labels, but never crash.
|
||||
# - Atoms: label directly.
|
||||
# - Binaries: only translate if they are known member fields (allowlist); otherwise return the string.
|
||||
# This avoids String.to_existing_atom/1 exceptions for arbitrary keys (e.g., "custom_field_...").
|
||||
defp label_for_column(key) when is_atom(key) do
|
||||
MemberFields.label(key)
|
||||
end
|
||||
|
||||
defp label_for_column(key) when is_binary(key) do
|
||||
if key in @allowed_member_field_strings do
|
||||
# Safe because key is in allowlist which originates from existing atoms
|
||||
MemberFields.label(String.to_existing_atom(key))
|
||||
else
|
||||
key
|
||||
end
|
||||
end
|
||||
|
||||
defp label_for_column(key) do
|
||||
to_string(key)
|
||||
end
|
||||
|
||||
# --- JSON responses ---
|
||||
|
||||
defp bad_request(conn, message) when is_binary(message) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "bad_request", message: message})
|
||||
end
|
||||
|
||||
defp unprocessable_entity(conn, body) when is_map(body) do
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(body)
|
||||
end
|
||||
|
||||
defp internal_error(conn, body) when is_map(body) do
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> json(body)
|
||||
end
|
||||
end
|
||||
|
|
@ -92,6 +92,7 @@ defmodule MvWeb.Router do
|
|||
live "/admin/import-export", ImportExportLive
|
||||
|
||||
post "/members/export.csv", MemberExportController, :export
|
||||
post "/members/export.pdf", MemberPdfExportController, :export
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue