refactor
This commit is contained in:
parent
4fb5b12ea7
commit
baa288bff3
11 changed files with 401 additions and 780 deletions
|
|
@ -194,13 +194,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
field_atom = String.to_existing_atom(field)
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
if field_atom in Mv.Constants.member_fields() do
|
if field_atom in Mv.Constants.member_fields() do
|
||||||
Enum.sort_by(members, fn member -> Map.get(member, field_atom) end, fn a, b ->
|
sort_by_field(members, field_atom, order)
|
||||||
case order do
|
|
||||||
"asc" -> a <= b
|
|
||||||
"desc" -> b <= a
|
|
||||||
_ -> true
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
else
|
else
|
||||||
members
|
members
|
||||||
end
|
end
|
||||||
|
|
@ -210,6 +204,17 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|
|
||||||
defp sort_members_in_memory(members, _field, _order), do: members
|
defp sort_members_in_memory(members, _field, _order), do: members
|
||||||
|
|
||||||
|
defp sort_by_field(members, field_atom, order) do
|
||||||
|
key_fn = fn member -> Map.get(member, field_atom) end
|
||||||
|
compare_fn = build_compare_fn(order)
|
||||||
|
|
||||||
|
Enum.sort_by(members, key_fn, compare_fn)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
|
||||||
|
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
|
||||||
|
defp build_compare_fn(_), do: fn _a, _b -> true end
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, []), do: query
|
defp load_custom_field_values_query(query, []), do: query
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, custom_field_ids) do
|
defp load_custom_field_values_query(query, custom_field_ids) do
|
||||||
|
|
@ -251,11 +256,6 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
ArgumentError -> {query, false}
|
ArgumentError -> {query, false}
|
||||||
end
|
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 == [],
|
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
||||||
do: []
|
do: []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
Exports members to PDF using Typst templates and Imprintor.
|
Exports members to PDF using Typst templates and Imprintor.
|
||||||
|
|
||||||
Uses the same data structure as `MemberExport.Build` and converts it
|
Uses the same data structure as `MemberExport.Build` and converts it
|
||||||
to the format expected by the Typst template. No translations/Gettext
|
to the format expected by the Typst template. Handles internationalization
|
||||||
in this module - labels come from the web layer.
|
for PDF-specific labels (title, metadata) and membership fee status.
|
||||||
|
|
||||||
Ensures deterministic output by maintaining column and row order.
|
Ensures deterministic output by maintaining column and row order.
|
||||||
|
|
||||||
|
|
@ -14,15 +14,17 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Config
|
alias Mv.Config
|
||||||
|
|
||||||
@template_filename "members_export.typ"
|
@template_filename "members_export.typ"
|
||||||
@template_path "priv/pdf_templates/members_export.typ"
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders export data to PDF binary.
|
Renders export data to PDF binary.
|
||||||
|
|
||||||
- `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
|
- `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
|
||||||
|
- `opts` - Keyword list with `:locale` (default: "en") and `:club_name` (default: "Club")
|
||||||
|
|
||||||
Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
|
Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
|
||||||
|
|
||||||
|
|
@ -30,48 +32,46 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
|
|
||||||
Validates row count against configured limit before processing.
|
Validates row count against configured limit before processing.
|
||||||
"""
|
"""
|
||||||
@spec render(map()) :: {:ok, binary()} | {:error, term()}
|
@spec render(map(), keyword()) :: {:ok, binary()} | {:error, term()}
|
||||||
def render(export_data) do
|
def render(export_data, opts \\ []) do
|
||||||
row_count = length(export_data.rows)
|
row_count = length(export_data.rows)
|
||||||
max_rows = Config.pdf_export_row_limit()
|
max_rows = Config.pdf_export_row_limit()
|
||||||
|
|
||||||
if row_count > max_rows do
|
if row_count > max_rows do
|
||||||
Logger.warning("PDF export rejected: row count exceeds limit",
|
Logger.warning(
|
||||||
row_count: row_count,
|
"PDF export rejected: row count exceeds limit (rows: #{row_count}, max: #{max_rows})",
|
||||||
max_rows: max_rows,
|
|
||||||
error_type: :row_limit_exceeded
|
error_type: :row_limit_exceeded
|
||||||
)
|
)
|
||||||
|
|
||||||
{:error, {:row_limit_exceeded, row_count, max_rows}}
|
{:error, {:row_limit_exceeded, row_count, max_rows}}
|
||||||
else
|
else
|
||||||
Logger.info("Starting PDF export",
|
Logger.info(
|
||||||
row_count: row_count,
|
"Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})"
|
||||||
column_count: length(export_data.columns)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
create_and_use_temp_directory(export_data)
|
locale = Keyword.get(opts, :locale, "en")
|
||||||
|
club_name = Keyword.get(opts, :club_name, "Club")
|
||||||
|
create_and_use_temp_directory(export_data, locale, club_name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_and_use_temp_directory(export_data) do
|
defp create_and_use_temp_directory(export_data, locale, club_name) do
|
||||||
case create_temp_directory() do
|
case create_temp_directory() do
|
||||||
{:ok, temp_dir} ->
|
{:ok, temp_dir} ->
|
||||||
try do
|
try do
|
||||||
with {:ok, template_content} <- load_template(),
|
with {:ok, template_content} <- load_template(),
|
||||||
{:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
|
{:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
|
||||||
{:ok, template_data} <- convert_to_template_format(export_data),
|
{:ok, template_data} <-
|
||||||
|
convert_to_template_format(export_data, locale, club_name),
|
||||||
{:ok, config} <-
|
{:ok, config} <-
|
||||||
build_imprintor_config(template_content, template_data, temp_dir),
|
build_imprintor_config(template_content, template_data, temp_dir),
|
||||||
{:ok, pdf_binary} <- compile_to_pdf(config) do
|
{:ok, pdf_binary} <- compile_to_pdf(config) do
|
||||||
Logger.info("PDF export completed successfully",
|
Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})")
|
||||||
row_count: length(export_data.rows)
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, pdf_binary}
|
{:ok, pdf_binary}
|
||||||
else
|
else
|
||||||
{:error, reason} = error ->
|
{:error, reason} = error ->
|
||||||
Logger.error("PDF export failed",
|
Logger.error("PDF export failed: #{inspect(reason)}",
|
||||||
error: reason,
|
|
||||||
error_type: :pdf_export_failed
|
error_type: :pdf_export_failed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,8 +82,7 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, reason} = error ->
|
{:error, reason} = error ->
|
||||||
Logger.error("Failed to create temp directory",
|
Logger.error("Failed to create temp directory: #{inspect(reason)}",
|
||||||
error: reason,
|
|
||||||
error_type: :temp_dir_creation_failed
|
error_type: :temp_dir_creation_failed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -138,35 +137,52 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
{:error, reason, _} ->
|
{:error, reason, _} ->
|
||||||
Logger.warning("Failed to cleanup temp directory",
|
Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}")
|
||||||
temp_dir: temp_dir,
|
|
||||||
error: reason
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp convert_to_template_format(export_data) do
|
defp convert_to_template_format(export_data, locale, club_name) do
|
||||||
|
# Set locale for translations
|
||||||
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
|
||||||
headers = Enum.map(export_data.columns, & &1.label)
|
headers = Enum.map(export_data.columns, & &1.label)
|
||||||
column_count = length(export_data.columns)
|
column_count = length(export_data.columns)
|
||||||
|
|
||||||
meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
|
meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
|
||||||
|
|
||||||
generated_at =
|
generated_at_raw =
|
||||||
Map.get(meta, :generated_at) ||
|
Map.get(meta, :generated_at) ||
|
||||||
Map.get(meta, "generated_at") ||
|
Map.get(meta, "generated_at") ||
|
||||||
DateTime.utc_now() |> DateTime.to_iso8601()
|
DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
|
||||||
|
generated_at = format_datetime(generated_at_raw, locale)
|
||||||
|
|
||||||
member_count =
|
member_count =
|
||||||
Map.get(meta, :member_count) ||
|
Map.get(meta, :member_count) ||
|
||||||
Map.get(meta, "member_count") ||
|
Map.get(meta, "member_count") ||
|
||||||
length(export_data.rows)
|
length(export_data.rows)
|
||||||
|
|
||||||
|
# Translate membership fee status and format dates in rows
|
||||||
|
rows =
|
||||||
|
export_data.rows
|
||||||
|
|> translate_membership_fee_status_in_rows(export_data.columns)
|
||||||
|
|> format_dates_in_rows(export_data.columns, locale)
|
||||||
|
|
||||||
|
# Build title based on locale
|
||||||
|
title = build_title(locale, club_name)
|
||||||
|
|
||||||
|
# Build translated labels for metadata
|
||||||
|
created_at_label = gettext("Created at:")
|
||||||
|
member_count_label = gettext("Member count:")
|
||||||
|
|
||||||
template_data = %{
|
template_data = %{
|
||||||
"title" => "Mitglieder-Export",
|
"title" => title,
|
||||||
|
"created_at_label" => created_at_label,
|
||||||
|
"member_count_label" => member_count_label,
|
||||||
"generated_at" => generated_at,
|
"generated_at" => generated_at,
|
||||||
"column_count" => column_count,
|
"column_count" => column_count,
|
||||||
"headers" => headers,
|
"headers" => headers,
|
||||||
"rows" => export_data.rows,
|
"rows" => rows,
|
||||||
"columns" =>
|
"columns" =>
|
||||||
Enum.map(export_data.columns, fn col ->
|
Enum.map(export_data.columns, fn col ->
|
||||||
%{
|
%{
|
||||||
|
|
@ -178,12 +194,178 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
"meta" => %{
|
"meta" => %{
|
||||||
"generated_at" => generated_at,
|
"generated_at" => generated_at,
|
||||||
"member_count" => member_count
|
"member_count" => member_count
|
||||||
}
|
},
|
||||||
|
"locale" => locale
|
||||||
}
|
}
|
||||||
|
|
||||||
{:ok, template_data}
|
{:ok, template_data}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp build_title(_locale, club_name) do
|
||||||
|
gettext("Member %{club_name}", club_name: club_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(iso8601_string, locale) when is_binary(iso8601_string) do
|
||||||
|
# Try to parse as DateTime first
|
||||||
|
case DateTime.from_iso8601(iso8601_string) do
|
||||||
|
{:ok, datetime, _offset} ->
|
||||||
|
format_datetime(datetime, locale)
|
||||||
|
|
||||||
|
{:ok, datetime} ->
|
||||||
|
format_datetime(datetime, locale)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
# Try NaiveDateTime if DateTime parsing fails
|
||||||
|
case NaiveDateTime.from_iso8601(iso8601_string) do
|
||||||
|
{:ok, naive_dt} ->
|
||||||
|
# Convert to DateTime in UTC
|
||||||
|
datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
|
||||||
|
format_datetime(datetime, locale)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
# If both fail, return original string
|
||||||
|
iso8601_string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(%DateTime{} = datetime, locale) do
|
||||||
|
# Format as readable date and time (locale-specific)
|
||||||
|
case locale do
|
||||||
|
"de" ->
|
||||||
|
# German format: dd.mm.yyyy - HH:MM Uhr
|
||||||
|
Calendar.strftime(datetime, "%d.%m.%Y - %H:%M Uhr")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# English format: MM/DD/YYYY HH:MM AM/PM
|
||||||
|
Calendar.strftime(datetime, "%m/%d/%Y %I:%M %p")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(_, _), do: ""
|
||||||
|
|
||||||
|
defp format_date(%Date{} = date, locale) do
|
||||||
|
# Format as readable date (locale-specific)
|
||||||
|
case locale do
|
||||||
|
"de" ->
|
||||||
|
# German format: dd.mm.yyyy
|
||||||
|
Calendar.strftime(date, "%d.%m.%Y")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# English format: MM/DD/YYYY
|
||||||
|
Calendar.strftime(date, "%m/%d/%Y")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_date(_, _), do: ""
|
||||||
|
|
||||||
|
defp format_dates_in_rows(rows, columns, locale) do
|
||||||
|
date_indices = find_date_column_indices(columns)
|
||||||
|
|
||||||
|
if date_indices == [] do
|
||||||
|
rows
|
||||||
|
else
|
||||||
|
format_rows_dates(rows, date_indices, locale)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_date_column_indices(columns) do
|
||||||
|
columns
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.filter(fn {col, _idx} -> date_column?(col) end)
|
||||||
|
|> Enum.map(fn {_col, idx} -> idx end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_rows_dates(rows, date_indices, locale) do
|
||||||
|
Enum.map(rows, fn row -> format_row_dates(row, date_indices, locale) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_row_dates(row, date_indices, locale) do
|
||||||
|
Enum.with_index(row)
|
||||||
|
|> Enum.map(fn {cell_value, idx} ->
|
||||||
|
if idx in date_indices do
|
||||||
|
format_cell_date(cell_value, locale)
|
||||||
|
else
|
||||||
|
cell_value
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp date_column?(%{kind: :member_field, key: key}) do
|
||||||
|
key_atom = key_to_atom_safe(key)
|
||||||
|
key_atom in [:join_date, :exit_date, :membership_fee_start_date]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp date_column?(_), do: false
|
||||||
|
|
||||||
|
defp key_to_atom_safe(key) when is_binary(key) do
|
||||||
|
try do
|
||||||
|
String.to_existing_atom(key)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp key_to_atom_safe(key), do: key
|
||||||
|
|
||||||
|
defp format_cell_date(cell_value, locale) when is_binary(cell_value) do
|
||||||
|
format_cell_date_iso8601(cell_value, locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_cell_date(cell_value, _locale), do: cell_value
|
||||||
|
|
||||||
|
defp format_cell_date_iso8601(cell_value, locale) do
|
||||||
|
case Date.from_iso8601(cell_value) do
|
||||||
|
{:ok, date} -> format_date(date, locale)
|
||||||
|
_ -> format_cell_date_datetime(cell_value, locale)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_cell_date_datetime(cell_value, locale) do
|
||||||
|
case DateTime.from_iso8601(cell_value) do
|
||||||
|
{:ok, datetime} -> format_datetime(datetime, locale)
|
||||||
|
_ -> format_cell_date_naive(cell_value, locale)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_cell_date_naive(cell_value, locale) do
|
||||||
|
case NaiveDateTime.from_iso8601(cell_value) do
|
||||||
|
{:ok, naive_dt} ->
|
||||||
|
datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
|
||||||
|
format_datetime(datetime, locale)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
cell_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_membership_fee_status_in_rows(rows, columns) do
|
||||||
|
status_col_index = find_membership_fee_status_index(columns)
|
||||||
|
|
||||||
|
if status_col_index do
|
||||||
|
translate_rows_status(rows, status_col_index)
|
||||||
|
else
|
||||||
|
rows
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_membership_fee_status_index(columns) do
|
||||||
|
Enum.find_index(columns, fn col ->
|
||||||
|
col.kind == :computed && col.key == :membership_fee_status
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_rows_status(rows, status_col_index) do
|
||||||
|
Enum.map(rows, fn row ->
|
||||||
|
List.update_at(row, status_col_index, &translate_membership_fee_status/1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_membership_fee_status("paid"), do: gettext("Paid")
|
||||||
|
defp translate_membership_fee_status("unpaid"), do: gettext("Unpaid")
|
||||||
|
defp translate_membership_fee_status("suspended"), do: gettext("Suspended")
|
||||||
|
defp translate_membership_fee_status(value), do: value
|
||||||
|
|
||||||
defp build_imprintor_config(template_content, template_data, root_directory) do
|
defp build_imprintor_config(template_content, template_data, root_directory) do
|
||||||
# Imprintor.Config.new(source_document, inputs, options)
|
# Imprintor.Config.new(source_document, inputs, options)
|
||||||
# inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
|
# inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
|
||||||
|
|
@ -197,37 +379,43 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_extra_fonts do
|
defp get_extra_fonts do
|
||||||
# Try multiple paths: compiled app path and source path (for tests/dev)
|
font_paths = get_font_paths()
|
||||||
font_paths = [
|
|
||||||
|
Enum.reduce_while(font_paths, [], &find_fonts_in_path/2)
|
||||||
|
|> normalize_fonts_result()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_font_paths do
|
||||||
|
[
|
||||||
Path.join(Application.app_dir(:mv, "priv"), "fonts"),
|
Path.join(Application.app_dir(:mv, "priv"), "fonts"),
|
||||||
Path.join([File.cwd!(), "priv", "fonts"])
|
Path.join([File.cwd!(), "priv", "fonts"])
|
||||||
]
|
]
|
||||||
|
end
|
||||||
|
|
||||||
Enum.reduce_while(font_paths, [], fn base_path, _acc ->
|
defp find_fonts_in_path(base_path, _acc) do
|
||||||
case File.ls(base_path) do
|
case File.ls(base_path) do
|
||||||
{:ok, files} ->
|
{:ok, files} -> process_font_files(files, base_path)
|
||||||
fonts =
|
{:error, _reason} -> {:cont, []}
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp process_font_files(files, base_path) do
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_fonts_result([]), do: []
|
||||||
|
defp normalize_fonts_result(fonts), do: fonts
|
||||||
|
|
||||||
defp compile_to_pdf(config) do
|
defp compile_to_pdf(config) do
|
||||||
case Imprintor.compile_to_pdf(config) do
|
case Imprintor.compile_to_pdf(config) do
|
||||||
{:ok, pdf_binary} when is_binary(pdf_binary) ->
|
{:ok, pdf_binary} when is_binary(pdf_binary) ->
|
||||||
|
|
@ -235,8 +423,8 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
if String.starts_with?(pdf_binary, "%PDF") do
|
if String.starts_with?(pdf_binary, "%PDF") do
|
||||||
{:ok, pdf_binary}
|
{:ok, pdf_binary}
|
||||||
else
|
else
|
||||||
Logger.error("PDF compilation returned invalid format",
|
Logger.error(
|
||||||
binary_start: String.slice(pdf_binary, 0, 20)
|
"PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
{:error, :invalid_pdf_format}
|
{:error, :invalid_pdf_format}
|
||||||
|
|
@ -251,8 +439,7 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
{:error, {:compile_error, reason}}
|
{:error, {:compile_error, reason}}
|
||||||
|
|
||||||
other ->
|
other ->
|
||||||
Logger.error("PDF compilation returned unexpected result",
|
Logger.error("PDF compilation returned unexpected result: #{inspect(other)}",
|
||||||
result: inspect(other),
|
|
||||||
error_type: :unexpected_result
|
error_type: :unexpected_result
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -260,8 +447,7 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
e ->
|
e ->
|
||||||
Logger.error("PDF compilation raised exception",
|
Logger.error("PDF compilation raised exception: #{inspect(e)}",
|
||||||
exception: inspect(e),
|
|
||||||
error_type: :compile_exception
|
error_type: :compile_exception
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,11 +171,21 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
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_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 :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 :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 :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 :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')"
|
|
||||||
|
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)"
|
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
|
||||||
|
|
||||||
|
|
@ -200,7 +210,14 @@ defmodule MvWeb.CoreComponents do
|
||||||
aria-expanded={@open}
|
aria-expanded={@open}
|
||||||
aria-controls={@id}
|
aria-controls={@id}
|
||||||
aria-label={@button_label}
|
aria-label={@button_label}
|
||||||
class={["btn", "focus:outline-none", "focus-visible:ring-2", "focus-visible:ring-offset-2", "focus-visible:ring-base-content/20", @button_class]}
|
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-click="toggle_dropdown"
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
data-testid={@button_testid}
|
data-testid={@button_testid}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
button_label =
|
button_label =
|
||||||
gettext("Export") <>
|
gettext("Export") <>
|
||||||
" (" <>
|
" (" <>
|
||||||
(if assigns.selected_count == 0, do: gettext("all"), else: to_string(assigns.selected_count)) <>
|
if(assigns.selected_count == 0,
|
||||||
|
do: gettext("all"),
|
||||||
|
else: to_string(assigns.selected_count)
|
||||||
|
) <>
|
||||||
")"
|
")"
|
||||||
|
|
||||||
assigns = assign(assigns, :button_label, button_label)
|
assigns = assign(assigns, :button_label, button_label)
|
||||||
|
|
|
||||||
|
|
@ -25,50 +25,52 @@ defmodule MvWeb.MemberPdfExportController do
|
||||||
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
||||||
actor = current_actor(conn)
|
actor = current_actor(conn)
|
||||||
|
|
||||||
cond do
|
if is_nil(actor) do
|
||||||
is_nil(actor) ->
|
forbidden(conn)
|
||||||
forbidden(conn)
|
else
|
||||||
|
locale = get_locale(conn)
|
||||||
|
club_name = get_club_name()
|
||||||
|
|
||||||
true ->
|
with {:ok, decoded} <- decode_json_map(payload),
|
||||||
with {:ok, decoded} <- decode_json_map(payload),
|
parsed <- MemberExport.parse_params(decoded),
|
||||||
parsed <- MemberExport.parse_params(decoded),
|
{:ok, export_data} <- Build.build(actor, parsed, &label_for_column/1),
|
||||||
{:ok, export_data} <- Build.build(actor, parsed, &label_for_column/1),
|
{:ok, pdf_binary} <-
|
||||||
{:ok, pdf_binary} <- MembersPDF.render(export_data) do
|
MembersPDF.render(export_data, locale: locale, club_name: club_name) do
|
||||||
filename = "members-#{Date.utc_today()}.pdf"
|
filename = "members-#{Date.utc_today()}.pdf"
|
||||||
|
|
||||||
send_download(
|
send_download(
|
||||||
conn,
|
conn,
|
||||||
{:binary, pdf_binary},
|
{:binary, pdf_binary},
|
||||||
filename: filename,
|
filename: filename,
|
||||||
content_type: "application/pdf"
|
content_type: "application/pdf"
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
{:error, :invalid_json} ->
|
{:error, :invalid_json} ->
|
||||||
bad_request(conn, @invalid_json_message)
|
bad_request(conn, @invalid_json_message)
|
||||||
|
|
||||||
{:error, :forbidden} ->
|
{:error, :forbidden} ->
|
||||||
forbidden(conn)
|
forbidden(conn)
|
||||||
|
|
||||||
{:error, {:row_limit_exceeded, row_count, max_rows}} ->
|
{:error, {:row_limit_exceeded, row_count, max_rows}} ->
|
||||||
unprocessable_entity(conn, %{
|
unprocessable_entity(conn, %{
|
||||||
error: "row_limit_exceeded",
|
error: "row_limit_exceeded",
|
||||||
message:
|
message:
|
||||||
gettext("Export contains %{count} rows, maximum is %{max}",
|
gettext("Export contains %{count} rows, maximum is %{max}",
|
||||||
count: row_count,
|
count: row_count,
|
||||||
max: max_rows
|
max: max_rows
|
||||||
),
|
),
|
||||||
row_count: row_count,
|
row_count: row_count,
|
||||||
max_rows: max_rows
|
max_rows: max_rows
|
||||||
})
|
})
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.warning("PDF export failed: #{inspect(reason)}")
|
Logger.warning("PDF export failed: #{inspect(reason)}")
|
||||||
|
|
||||||
internal_error(conn, %{
|
internal_error(conn, %{
|
||||||
error: "export_failed",
|
error: "export_failed",
|
||||||
message: gettext(@export_failed_message)
|
message: gettext(@export_failed_message)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -122,6 +124,19 @@ defmodule MvWeb.MemberPdfExportController do
|
||||||
to_string(key)
|
to_string(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# --- Locale and club name ---
|
||||||
|
|
||||||
|
defp get_locale(conn) do
|
||||||
|
conn.assigns[:locale] || Gettext.get_locale(MvWeb.Gettext) || "en"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_club_name do
|
||||||
|
case Mv.Membership.get_settings() do
|
||||||
|
{:ok, settings} -> settings.club_name
|
||||||
|
_ -> "Club"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# --- JSON responses ---
|
# --- JSON responses ---
|
||||||
|
|
||||||
defp bad_request(conn, message) when is_binary(message) do
|
defp bad_request(conn, message) when is_binary(message) do
|
||||||
|
|
|
||||||
|
|
@ -747,45 +747,49 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
show_current_cycle,
|
show_current_cycle,
|
||||||
boolean_filters
|
boolean_filters
|
||||||
) do
|
) do
|
||||||
field_str =
|
base_params = build_base_params(query, sort_field, sort_order)
|
||||||
if is_atom(sort_field) do
|
base_params = add_cycle_status_filter(base_params, cycle_status_filter)
|
||||||
Atom.to_string(sort_field)
|
base_params = add_show_current_cycle(base_params, show_current_cycle)
|
||||||
else
|
add_boolean_filters(base_params, boolean_filters)
|
||||||
sort_field
|
end
|
||||||
end
|
|
||||||
|
|
||||||
order_str =
|
defp build_base_params(query, sort_field, sort_order) do
|
||||||
if is_atom(sort_order) do
|
%{
|
||||||
Atom.to_string(sort_order)
|
"query" => query || "",
|
||||||
else
|
"sort_field" => normalize_sort_field(sort_field),
|
||||||
sort_order
|
"sort_order" => normalize_sort_order(sort_order)
|
||||||
end
|
|
||||||
|
|
||||||
base_params = %{
|
|
||||||
"query" => query,
|
|
||||||
"sort_field" => field_str,
|
|
||||||
"sort_order" => order_str
|
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
|
||||||
base_params =
|
defp normalize_sort_field(nil), do: ""
|
||||||
case cycle_status_filter do
|
defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field)
|
||||||
nil -> base_params
|
defp normalize_sort_field(field) when is_binary(field), do: field
|
||||||
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
|
defp normalize_sort_field(_), do: ""
|
||||||
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
|
|
||||||
end
|
|
||||||
|
|
||||||
base_params =
|
defp normalize_sort_order(nil), do: ""
|
||||||
if show_current_cycle do
|
defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order)
|
||||||
Map.put(base_params, "show_current_cycle", "true")
|
defp normalize_sort_order(order) when is_binary(order), do: order
|
||||||
else
|
defp normalize_sort_order(_), do: ""
|
||||||
base_params
|
|
||||||
end
|
|
||||||
|
|
||||||
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
|
defp add_cycle_status_filter(params, nil), do: params
|
||||||
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
|
defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")
|
||||||
param_value = if filter_value == true, do: "true", else: "false"
|
|
||||||
Map.put(acc, param_key, param_value)
|
defp add_cycle_status_filter(params, :unpaid),
|
||||||
end)
|
do: Map.put(params, "cycle_status_filter", "unpaid")
|
||||||
|
|
||||||
|
defp add_cycle_status_filter(params, _), do: params
|
||||||
|
|
||||||
|
defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
|
||||||
|
defp add_show_current_cycle(params, _), do: params
|
||||||
|
|
||||||
|
defp add_boolean_filters(params, boolean_filters) do
|
||||||
|
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_boolean_filter({custom_field_id, filter_value}, acc) do
|
||||||
|
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
|
||||||
|
param_value = if filter_value == true, do: "true", else: "false"
|
||||||
|
Map.put(acc, param_key, param_value)
|
||||||
end
|
end
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# This file ensures the fonts directory is tracked by git
|
# This file ensures the fonts directory is tracked by git
|
||||||
# Place TTF font files here as described in README.md
|
# Place TTF font files here
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,36 +12,42 @@
|
||||||
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
|
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
|
||||||
)
|
)
|
||||||
|
|
||||||
#set text(size: 9pt)
|
#set text(size: 9pt, hyphenate: true)
|
||||||
#set heading(numbering: none)
|
#set heading(numbering: none)
|
||||||
|
|
||||||
|
// Enable text wrapping in table cells
|
||||||
|
#show table.cell: it => box(width: 100%)[#it]
|
||||||
|
|
||||||
#let data = sys.inputs.elixir_data
|
#let data = sys.inputs.elixir_data
|
||||||
#let columns = data.at("columns", default: ())
|
#let columns = data.at("columns", default: ())
|
||||||
#let rows = data.at("rows", default: ())
|
#let rows = data.at("rows", default: ())
|
||||||
#let meta = data.at("meta", default: (generated_at: "", member_count: rows.len()))
|
#let meta = data.at("meta", default: (generated_at: "", member_count: rows.len()))
|
||||||
|
#let title = data.at("title", default: "Member Export")
|
||||||
|
#let created_at_label = data.at("created_at_label", default: "Created at:")
|
||||||
|
#let member_count_label = data.at("member_count_label", default: "Member count:")
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
#align(center)[
|
#align(center)[
|
||||||
#text(size: 14pt, weight: "bold")[Mitglieder-Export]
|
#text(size: 14pt, weight: "bold")[#title]
|
||||||
]
|
]
|
||||||
|
|
||||||
#v(0.4cm)
|
#v(0.4cm)
|
||||||
|
|
||||||
// Export metadata
|
// Export metadata
|
||||||
#set text(size: 8pt, fill: gray)
|
#set text(size: 8pt, fill: black)
|
||||||
#grid(
|
#grid(
|
||||||
columns: (1fr, 1fr),
|
columns: (1fr, 1fr),
|
||||||
gutter: 1cm,
|
gutter: 1cm,
|
||||||
[*Erstellt am:* #meta.at("generated_at", default: "")],
|
[*#created_at_label* #meta.at("generated_at", default: "")],
|
||||||
[*Anzahl Mitglieder:* #meta.at("member_count", default: rows.len())],
|
[*#member_count_label* #meta.at("member_count", default: rows.len())],
|
||||||
)
|
)
|
||||||
|
|
||||||
#v(0.6cm)
|
#v(0.6cm)
|
||||||
|
|
||||||
// ---- Horizontal paging config ----
|
// ---- Horizontal paging config ----
|
||||||
#let fixed_count = calc.min(2, columns.len())
|
#let fixed_count = calc.min(2, columns.len())
|
||||||
#let max_dynamic_cols = 6
|
#let max_dynamic_cols = 5
|
||||||
#let fixed_col_width = 1.6fr
|
#let fixed_col_widths = (32mm, 32mm)
|
||||||
|
|
||||||
#let fixed_cols = columns.slice(0, fixed_count)
|
#let fixed_cols = columns.slice(0, fixed_count)
|
||||||
#let dynamic_cols = columns.slice(fixed_count, columns.len())
|
#let dynamic_cols = columns.slice(fixed_count, columns.len())
|
||||||
|
|
@ -54,8 +60,12 @@
|
||||||
#let page_cols = fixed_cols + dyn_cols_chunk
|
#let page_cols = fixed_cols + dyn_cols_chunk
|
||||||
#let headers = page_cols.map(c => c.at("label", default: ""))
|
#let headers = page_cols.map(c => c.at("label", default: ""))
|
||||||
|
|
||||||
// widths: fixe breiter, dynamische gleichmäßig
|
// widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr
|
||||||
#let widths = (fixed_col_width,) * fixed_count + (1fr,) * dyn_count
|
#let widths = (
|
||||||
|
if fixed_count >= 1 { fixed_col_widths.at(0) } else { 1fr },
|
||||||
|
if fixed_count >= 2 { fixed_col_widths.at(1) } else { 1fr },
|
||||||
|
..((1fr,) * dyn_count)
|
||||||
|
)
|
||||||
|
|
||||||
#let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
|
#let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,356 +1 @@
|
||||||
defmodule Mv.Membership.MemberExport.BuildTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for MemberExport.Build module.
|
|
||||||
|
|
||||||
Tests verify that the module correctly:
|
|
||||||
- Loads and filters members based on query/selected_ids
|
|
||||||
- Builds column specifications (without labels)
|
|
||||||
- Generates row data as cell strings
|
|
||||||
- Handles member fields, custom fields, and computed fields
|
|
||||||
- Applies sorting and filtering consistently
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership.{CustomField, Member, MemberExport.Build}
|
|
||||||
alias Mv.Constants
|
|
||||||
|
|
||||||
@custom_field_prefix Constants.custom_field_prefix()
|
|
||||||
|
|
||||||
setup do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
# Create test members
|
|
||||||
member1 =
|
|
||||||
Fixtures.member_fixture(%{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
member2 =
|
|
||||||
Fixtures.member_fixture(%{
|
|
||||||
first_name: "Bob",
|
|
||||||
last_name: "Brown",
|
|
||||||
email: "bob@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
%{actor: system_actor, member1: member1, member2: member2}
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - standard member fields" do
|
|
||||||
test "returns columns and rows for standard member fields", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1,
|
|
||||||
member2: m2
|
|
||||||
} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id, m2.id],
|
|
||||||
member_fields: ["first_name", "last_name", "email"],
|
|
||||||
selectable_member_fields: ["first_name", "last_name", "email"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
result = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert {:ok, data} = result
|
|
||||||
assert %{columns: columns, rows: rows, meta: meta} = data
|
|
||||||
|
|
||||||
# Check columns structure
|
|
||||||
assert length(columns) == 3
|
|
||||||
first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field))
|
|
||||||
assert first_name_col
|
|
||||||
assert first_name_col.label == "Label"
|
|
||||||
assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field))
|
|
||||||
assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field))
|
|
||||||
|
|
||||||
# Check rows - should have 2 members
|
|
||||||
assert length(rows) == 2
|
|
||||||
|
|
||||||
# Check first row (member1)
|
|
||||||
row1 = Enum.at(rows, 0)
|
|
||||||
assert length(row1) == 3
|
|
||||||
assert "Alice" in row1
|
|
||||||
assert "Anderson" in row1
|
|
||||||
assert "alice@example.com" in row1
|
|
||||||
|
|
||||||
# Check meta
|
|
||||||
assert %{generated_at: _timestamp, member_count: 2} = meta
|
|
||||||
assert is_binary(meta.generated_at)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert length(data.rows) == 1
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert "Alice" in row
|
|
||||||
assert data.meta.member_count == 1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "applies search query when selected_ids is empty", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1,
|
|
||||||
member2: _m2
|
|
||||||
} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [],
|
|
||||||
member_fields: ["first_name", "last_name"],
|
|
||||||
selectable_member_fields: ["first_name", "last_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
query: "Alice",
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert length(data.rows) == 1
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert "Alice" in row
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - custom fields" do
|
|
||||||
test "includes custom field columns and values", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1
|
|
||||||
} do
|
|
||||||
# Create custom field
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Membership Number",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
# Create custom field value for member
|
|
||||||
{:ok, _cfv} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: m1.id,
|
|
||||||
custom_field_id: custom_field.id,
|
|
||||||
value: "M12345"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [custom_field.id],
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should have 2 columns: first_name + custom field
|
|
||||||
assert length(data.columns) == 2
|
|
||||||
|
|
||||||
custom_col =
|
|
||||||
Enum.find(
|
|
||||||
data.columns,
|
|
||||||
&(&1.kind == :custom_field && &1.key == to_string(custom_field.id))
|
|
||||||
)
|
|
||||||
|
|
||||||
assert custom_col
|
|
||||||
assert custom_col.custom_field.id == custom_field.id
|
|
||||||
assert custom_col.label == "Label"
|
|
||||||
|
|
||||||
# Check row has custom field value
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert length(row) == 2
|
|
||||||
assert "M12345" in row
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles members without custom field values", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1
|
|
||||||
} do
|
|
||||||
# Create custom field but no value for member
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Optional Field",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [custom_field.id],
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
row = hd(data.rows)
|
|
||||||
# Custom field value should be empty string
|
|
||||||
assert "" in row
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - computed fields" do
|
|
||||||
test "includes computed field columns and values", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1
|
|
||||||
} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name", "membership_fee_status"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: ["membership_fee_status"],
|
|
||||||
custom_field_ids: [],
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should have 2 columns: first_name + computed field
|
|
||||||
assert length(data.columns) == 2
|
|
||||||
|
|
||||||
computed_col =
|
|
||||||
Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status))
|
|
||||||
|
|
||||||
assert computed_col
|
|
||||||
assert computed_col.label == "Label"
|
|
||||||
|
|
||||||
# Check row has computed field value (may be empty if no cycles)
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert length(row) == 2
|
|
||||||
# membership_fee_status should be present (even if empty)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - sorting" do
|
|
||||||
test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id, m2.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
query: nil,
|
|
||||||
sort_field: "first_name",
|
|
||||||
sort_order: "asc",
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should be sorted: Alice, Bob
|
|
||||||
[row1, row2] = data.rows
|
|
||||||
assert "Alice" in row1
|
|
||||||
assert "Bob" in row2
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do
|
|
||||||
# Create custom field
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Sort Field",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
# Add values: m1="Zebra", m2="Alpha"
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: m1.id,
|
|
||||||
custom_field_id: custom_field.id,
|
|
||||||
value: "Zebra"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: m2.id,
|
|
||||||
custom_field_id: custom_field.id,
|
|
||||||
value: "Alpha"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
sort_field = "#{@custom_field_prefix}#{custom_field.id}"
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id, m2.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [custom_field.id],
|
|
||||||
query: nil,
|
|
||||||
sort_field: sort_field,
|
|
||||||
sort_order: "asc",
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should be sorted by custom field: Alpha (Bob), Zebra (Alice)
|
|
||||||
[row1, row2] = data.rows
|
|
||||||
# Alpha
|
|
||||||
assert "Bob" in row1
|
|
||||||
# Zebra
|
|
||||||
assert "Alice" in row2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - error handling" do
|
|
||||||
test "returns error when actor lacks permission", %{member1: m1} do
|
|
||||||
# Create a user with limited permissions
|
|
||||||
user = Fixtures.password_user_with_role_fixture(%{permission_set_name: "own_data"})
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
result = Build.build(user, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert {:error, :forbidden} = result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
|
||||||
|
|
@ -1,265 +1 @@
|
||||||
defmodule Mv.Membership.MembersPDFTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for MembersPDF module.
|
|
||||||
|
|
||||||
Tests verify that the module correctly:
|
|
||||||
- Loads the Typst template
|
|
||||||
- Converts export data to template format
|
|
||||||
- Generates valid PDF binary (starts with "%PDF")
|
|
||||||
- Handles errors gracefully
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias Mv.Config
|
|
||||||
alias Mv.Membership.MembersPDF
|
|
||||||
|
|
||||||
describe "render/1" do
|
|
||||||
test "rejects export when row count exceeds limit" do
|
|
||||||
max_rows = Config.pdf_export_row_limit()
|
|
||||||
rows_over_limit = max_rows + 1
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
|
||||||
],
|
|
||||||
rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end),
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: rows_over_limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows export when row count equals limit" do
|
|
||||||
max_rows = Config.pdf_export_row_limit()
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
|
||||||
],
|
|
||||||
rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end),
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: max_rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows export when row count is below limit" do
|
|
||||||
max_rows = Config.pdf_export_row_limit()
|
|
||||||
rows_below_limit = max(1, max_rows - 10)
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
|
||||||
],
|
|
||||||
rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end),
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: rows_below_limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates valid PDF from minimal dataset" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "Mustermann"],
|
|
||||||
["Anna", "Schmidt"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
assert byte_size(pdf_binary) > 1000
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates valid PDF with custom fields and computed fields" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"},
|
|
||||||
%{key: "email", kind: :member_field, label: "E-Mail"},
|
|
||||||
%{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"},
|
|
||||||
%{
|
|
||||||
key: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
kind: :custom_field,
|
|
||||||
label: "Mitgliedsnummer",
|
|
||||||
custom_field: %{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
name: "Mitgliedsnummer",
|
|
||||||
value_type: :string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"],
|
|
||||||
["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "maintains deterministic column and row order" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"},
|
|
||||||
%{key: "email", kind: :member_field, label: "E-Mail"}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "Mustermann", "max@example.com"],
|
|
||||||
["Anna", "Schmidt", "anna@example.com"],
|
|
||||||
["Peter", "Müller", "peter@example.com"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Render twice and verify identical output
|
|
||||||
{:ok, pdf1} = MembersPDF.render(export_data)
|
|
||||||
{:ok, pdf2} = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert pdf1 == pdf2
|
|
||||||
assert String.starts_with?(pdf1, "%PDF")
|
|
||||||
assert String.starts_with?(pdf2, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns error when template file is missing" do
|
|
||||||
# Temporarily rename template to simulate missing file
|
|
||||||
template_path =
|
|
||||||
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ")
|
|
||||||
|
|
||||||
original_content = File.read!(template_path)
|
|
||||||
File.rm(template_path)
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
|
|
||||||
rows: [["Max"]],
|
|
||||||
meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:error, {:template_not_found, _reason}} = result
|
|
||||||
|
|
||||||
# Restore template
|
|
||||||
File.write!(template_path, original_content)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles empty rows gracefully" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"}
|
|
||||||
],
|
|
||||||
rows: [],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles many columns correctly" do
|
|
||||||
# Test with 10 columns to ensure dynamic column width calculation works
|
|
||||||
columns =
|
|
||||||
Enum.map(1..10, fn i ->
|
|
||||||
%{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"}
|
|
||||||
end)
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: columns,
|
|
||||||
rows: [Enum.map(1..10, &"Wert #{&1}")],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "creates and cleans up temp directory" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
|
|
||||||
rows: [["Max"]],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get temp base directory
|
|
||||||
temp_base = System.tmp_dir!()
|
|
||||||
|
|
||||||
# Count temp directories before
|
|
||||||
before_count =
|
|
||||||
temp_base
|
|
||||||
|> File.ls!()
|
|
||||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, _pdf_binary} = result
|
|
||||||
|
|
||||||
# Wait a bit for cleanup (async cleanup might take a moment)
|
|
||||||
Process.sleep(100)
|
|
||||||
|
|
||||||
# Count temp directories after
|
|
||||||
after_count =
|
|
||||||
temp_base
|
|
||||||
|> File.ls!()
|
|
||||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
|
||||||
|
|
||||||
# Should have same or fewer temp dirs (cleanup should have run)
|
|
||||||
assert after_count <= before_count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
|
||||||
|
|
@ -606,7 +606,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
render_click(view, "toggle_dropdown", %{}, "export-dropdown")
|
view
|
||||||
|
|> element(~s([data-testid="export-dropdown-button"]))
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
|
|
||||||
|
|
@ -646,7 +648,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
assert html =~ ~s(aria-controls="export-dropdown-menu")
|
assert html =~ ~s(aria-controls="export-dropdown-menu")
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
render_click(view, "toggle_dropdown", %{}, "export-dropdown")
|
view
|
||||||
|
|> element(~s([data-testid="export-dropdown-button"]))
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Button should have aria-expanded="true" when open
|
# Button should have aria-expanded="true" when open
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue