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
|
|
@ -58,6 +58,12 @@ config :mv,
|
||||||
max_rows: 1000
|
max_rows: 1000
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# PDF Export configuration
|
||||||
|
config :mv,
|
||||||
|
pdf_export: [
|
||||||
|
row_limit: 5000
|
||||||
|
]
|
||||||
|
|
||||||
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
|
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
|
||||||
config :mv, :oidc_role_sync,
|
config :mv, :oidc_role_sync,
|
||||||
admin_group_name: nil,
|
admin_group_name: nil,
|
||||||
|
|
|
||||||
71
docs/pdf-generation-imprintor.md
Normal file
71
docs/pdf-generation-imprintor.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# PDF Generation: Imprintor statt Chromium
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Für die PDF-Generierung in der Mitgliederverwaltung verwenden wir **Imprintor** (`~> 0.5.0`) anstelle von Chromium-basierten Lösungen (wie z.B. Puppeteer, Chrome Headless, oder ähnliche).
|
||||||
|
|
||||||
|
## Warum Imprintor statt Chromium?
|
||||||
|
|
||||||
|
### 1. Ressourceneffizienz
|
||||||
|
|
||||||
|
- **Geringerer Speicherverbrauch**: Imprintor benötigt keine vollständige Browser-Instanz im Speicher
|
||||||
|
- **Niedrigere CPU-Last**: Native PDF-Generierung ohne Browser-Rendering-Pipeline
|
||||||
|
- **Kleinere Docker-Images**: Keine Chromium-Installation erforderlich (spart mehrere hundert MB)
|
||||||
|
|
||||||
|
### 2. Performance
|
||||||
|
|
||||||
|
- **Schnellere Generierung**: Direkte PDF-Generierung ohne HTML-Rendering-Overhead
|
||||||
|
- **Bessere Skalierbarkeit**: Kann mehrere PDFs parallel generieren ohne Browser-Instanzen zu verwalten
|
||||||
|
- **Niedrigere Latenz**: Keine Browser-Startup-Zeit
|
||||||
|
|
||||||
|
### 3. Deployment & Wartung
|
||||||
|
|
||||||
|
- **Einfacheres Deployment**: Keine System-Abhängigkeiten (Chromium, ChromeDriver, etc.)
|
||||||
|
- **Weniger Wartungsaufwand**: Keine Browser-Version-Updates zu verwalten
|
||||||
|
- **Bessere Container-Kompatibilität**: Funktioniert in minimalen Docker-Images (z.B. Alpine)
|
||||||
|
|
||||||
|
### 4. Sicherheit
|
||||||
|
|
||||||
|
- **Kleinere Angriffsfläche**: Keine Browser-Engine mit bekannten Sicherheitslücken
|
||||||
|
- **Isolation**: Weniger System-Calls und externe Prozesse
|
||||||
|
|
||||||
|
### 5. Elixir-Native Lösung
|
||||||
|
|
||||||
|
- **Erlang/OTP-Integration**: Nutzt die Vorteile der BEAM-VM (Concurrency, Fault Tolerance)
|
||||||
|
- **Type-Safety**: Bessere Integration mit Elixir-Typen und Pattern Matching
|
||||||
|
- **Einfachere Fehlerbehandlung**: Elixir-native Error-Handling statt externer Prozesse
|
||||||
|
|
||||||
|
## Wann Chromium trotzdem sinnvoll wäre
|
||||||
|
|
||||||
|
Chromium-basierte Lösungen sind sinnvoll, wenn:
|
||||||
|
- Komplexe JavaScript-Ausführung im HTML nötig ist
|
||||||
|
- Moderne CSS-Features (Grid, Flexbox, etc.) kritisch sind
|
||||||
|
- Screenshots von Web-Seiten generiert werden sollen
|
||||||
|
- Dynamische Inhalte gerendert werden müssen, die JavaScript erfordern
|
||||||
|
|
||||||
|
## Verwendung in diesem Projekt
|
||||||
|
|
||||||
|
Imprintor wird für folgende Anwendungsfälle verwendet:
|
||||||
|
- **Member-Export als PDF**: Generierung von Mitgliederlisten und -reports
|
||||||
|
- **Statische Reports**: PDF-Generierung für vordefinierte Report-Formate
|
||||||
|
- **Dokumente**: Generierung von Mitgliedschaftsbescheinigungen, Rechnungen, etc.
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
- **Dependency**: `{:imprintor, "~> 0.5.0"}`
|
||||||
|
- **Typ**: Native Elixir-Bibliothek (vermutlich basierend auf Rust-NIFs oder ähnlichen Technologien)
|
||||||
|
- **Format**: Generiert PDF direkt aus HTML/Templates ohne Browser-Engine
|
||||||
|
|
||||||
|
## Migration von Chromium (falls vorhanden)
|
||||||
|
|
||||||
|
Falls zuvor eine Chromium-basierte Lösung verwendet wurde:
|
||||||
|
1. HTML-Templates müssen ggf. angepasst werden (kein JavaScript-Support)
|
||||||
|
2. CSS muss statisch sein (keine dynamischen Styles)
|
||||||
|
3. Komplexe Layouts sollten vorher getestet werden
|
||||||
|
|
||||||
|
## Weitere Ressourcen
|
||||||
|
|
||||||
|
- [Imprintor auf Hex.pm](https://hex.pm/packages/imprintor)
|
||||||
|
- [GitHub Repository](https://github.com/[imprintor-repo]) (falls verfügbar)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,4 +116,30 @@ defmodule Mv.Config do
|
||||||
defp parse_and_validate_integer(_value, default) do
|
defp parse_and_validate_integer(_value, default) do
|
||||||
default
|
default
|
||||||
end
|
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
|
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
|
## Examples
|
||||||
|
|
||||||
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
|
<.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 :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 :button_label, :string, default: "Dropdown"
|
||||||
attr :icon, :string, default: nil
|
attr :icon, :string, default: nil
|
||||||
attr :checkboxes, :boolean, default: false
|
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 :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
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 :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
|
def dropdown_menu(assigns) do
|
||||||
|
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||||
|
assigns = assign(assigns, :menu_testid, menu_testid)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
|
@ -170,7 +190,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
phx-window-keydown="close_dropdown"
|
phx-window-keydown="close_dropdown"
|
||||||
phx-key="Escape"
|
phx-key="Escape"
|
||||||
data-testid="dropdown-menu"
|
data-testid={@testid}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -180,10 +200,10 @@ 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"
|
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="dropdown-button"
|
data-testid={@button_testid}
|
||||||
>
|
>
|
||||||
<%= if @icon do %>
|
<%= if @icon do %>
|
||||||
<.icon name={@icon} />
|
<.icon name={@icon} />
|
||||||
|
|
@ -195,69 +215,79 @@ defmodule MvWeb.CoreComponents do
|
||||||
:if={@open}
|
:if={@open}
|
||||||
id={@id}
|
id={@id}
|
||||||
role="menu"
|
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"
|
tabindex="0"
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
|
data-testid={@menu_testid}
|
||||||
>
|
>
|
||||||
<li :if={@show_select_buttons} role="none">
|
<%= if assigns.inner_block != [] do %>
|
||||||
<div class="flex justify-between items-center mb-2 px-2">
|
{render_slot(@inner_block)}
|
||||||
<span class="font-semibold">{gettext("Options")}</span>
|
<% else %>
|
||||||
<div class="flex gap-1">
|
<li :if={@show_select_buttons} role="none">
|
||||||
<button
|
<div class="flex justify-between items-center mb-2 px-2">
|
||||||
type="button"
|
<span class="font-semibold">{gettext("Options")}</span>
|
||||||
role="menuitem"
|
<div class="flex gap-1">
|
||||||
aria-label={gettext("Select all")}
|
<button
|
||||||
phx-click="select_all"
|
type="button"
|
||||||
phx-target={@phx_target}
|
role="menuitem"
|
||||||
class="btn btn-xs btn-ghost"
|
aria-label={gettext("Select all")}
|
||||||
>
|
phx-click="select_all"
|
||||||
{gettext("All")}
|
phx-target={@phx_target}
|
||||||
</button>
|
class="btn btn-xs btn-ghost"
|
||||||
<button
|
>
|
||||||
type="button"
|
{gettext("All")}
|
||||||
role="menuitem"
|
</button>
|
||||||
aria-label={gettext("Select none")}
|
<button
|
||||||
phx-click="select_none"
|
type="button"
|
||||||
phx-target={@phx_target}
|
role="menuitem"
|
||||||
class="btn btn-xs btn-ghost"
|
aria-label={gettext("Select none")}
|
||||||
>
|
phx-click="select_none"
|
||||||
{gettext("None")}
|
phx-target={@phx_target}
|
||||||
</button>
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
{gettext("None")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
<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 %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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
|
live "/admin/import-export", ImportExportLive
|
||||||
|
|
||||||
post "/members/export.csv", MemberExportController, :export
|
post "/members/export.csv", MemberExportController, :export
|
||||||
|
post "/members/export.pdf", MemberPdfExportController, :export
|
||||||
post "/set_locale", LocaleController, :set_locale
|
post "/set_locale", LocaleController, :set_locale
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
3
mix.exs
3
mix.exs
|
|
@ -79,7 +79,8 @@ defmodule Mv.MixProject do
|
||||||
{:picosat_elixir, "~> 0.1"},
|
{:picosat_elixir, "~> 0.1"},
|
||||||
{:ecto_commons, "~> 0.3"},
|
{:ecto_commons, "~> 0.3"},
|
||||||
{:slugify, "~> 1.3"},
|
{:slugify, "~> 1.3"},
|
||||||
{:nimble_csv, "~> 1.0"}
|
{:nimble_csv, "~> 1.0"},
|
||||||
|
{:imprintor, "~> 0.5.0"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -36,6 +36,7 @@
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
|
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
|
||||||
|
"imprintor": {:hex, :imprintor, "0.5.0", "3266aa8487cc6eab3915a578c79d49e489d1bacf959a6535b1ef32acc62d71cc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d4bbfbd26c2ddbb7eb38894b7412c0ef62f953cbb176df3cccbd266fe890c12f"},
|
||||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||||
|
|
@ -68,6 +69,7 @@
|
||||||
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
|
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
|
||||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||||
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
||||||
|
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
|
||||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||||
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
||||||
|
|
|
||||||
4
priv/fonts/.gitkeep
Normal file
4
priv/fonts/.gitkeep
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# This file ensures the fonts directory is tracked by git
|
||||||
|
# Place TTF font files here as described in README.md
|
||||||
|
|
||||||
|
|
||||||
85
priv/pdf_templates/members_export.typ
Normal file
85
priv/pdf_templates/members_export.typ
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Typst template for member export (PDF)
|
||||||
|
// Expected sys.inputs.elixir_data:
|
||||||
|
// {
|
||||||
|
// "columns": [{"key": "...", "kind": "...", "label": "..."}, ...],
|
||||||
|
// "rows": [["cell1", "cell2", ...], ...],
|
||||||
|
// "meta": {"generated_at": "...", "member_count": 123}
|
||||||
|
// }
|
||||||
|
|
||||||
|
#set page(
|
||||||
|
paper: "a4",
|
||||||
|
flipped: true,
|
||||||
|
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
|
||||||
|
)
|
||||||
|
|
||||||
|
#set text(size: 9pt)
|
||||||
|
#set heading(numbering: none)
|
||||||
|
|
||||||
|
#let data = sys.inputs.elixir_data
|
||||||
|
#let columns = data.at("columns", default: ())
|
||||||
|
#let rows = data.at("rows", default: ())
|
||||||
|
#let meta = data.at("meta", default: (generated_at: "", member_count: rows.len()))
|
||||||
|
|
||||||
|
// Title
|
||||||
|
#align(center)[
|
||||||
|
#text(size: 14pt, weight: "bold")[Mitglieder-Export]
|
||||||
|
]
|
||||||
|
|
||||||
|
#v(0.4cm)
|
||||||
|
|
||||||
|
// Export metadata
|
||||||
|
#set text(size: 8pt, fill: gray)
|
||||||
|
#grid(
|
||||||
|
columns: (1fr, 1fr),
|
||||||
|
gutter: 1cm,
|
||||||
|
[*Erstellt am:* #meta.at("generated_at", default: "")],
|
||||||
|
[*Anzahl Mitglieder:* #meta.at("member_count", default: rows.len())],
|
||||||
|
)
|
||||||
|
|
||||||
|
#v(0.6cm)
|
||||||
|
|
||||||
|
// ---- Horizontal paging config ----
|
||||||
|
#let fixed_count = calc.min(2, columns.len())
|
||||||
|
#let max_dynamic_cols = 6
|
||||||
|
#let fixed_col_width = 1.6fr
|
||||||
|
|
||||||
|
#let fixed_cols = columns.slice(0, fixed_count)
|
||||||
|
#let dynamic_cols = columns.slice(fixed_count, columns.len())
|
||||||
|
#let dynamic_chunks = dynamic_cols.chunks(max_dynamic_cols)
|
||||||
|
|
||||||
|
#let render_chunk(chunk_index, dyn_cols_chunk) = [
|
||||||
|
#let dyn_count = dyn_cols_chunk.len()
|
||||||
|
#let start = fixed_count + chunk_index * max_dynamic_cols
|
||||||
|
|
||||||
|
#let page_cols = fixed_cols + dyn_cols_chunk
|
||||||
|
#let headers = page_cols.map(c => c.at("label", default: ""))
|
||||||
|
|
||||||
|
// widths: fixe breiter, dynamische gleichmäßig
|
||||||
|
#let widths = (fixed_col_width,) * fixed_count + (1fr,) * dyn_count
|
||||||
|
|
||||||
|
#let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
|
||||||
|
|
||||||
|
// Body cells (row-major), nur die Spalten dieses Chunks
|
||||||
|
#let body_cells = (
|
||||||
|
rows
|
||||||
|
.map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count))
|
||||||
|
.map(cells => cells.map(cell => text(size: 8.5pt)[#cell]))
|
||||||
|
.flatten()
|
||||||
|
)
|
||||||
|
|
||||||
|
#table(
|
||||||
|
columns: widths,
|
||||||
|
table.header(..header_cells),
|
||||||
|
..body_cells,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---- Output ----
|
||||||
|
#if dynamic_cols.len() == 0 {
|
||||||
|
render_chunk(0, ())
|
||||||
|
} else {
|
||||||
|
for (i, chunk) in dynamic_chunks.enumerate() {
|
||||||
|
render_chunk(i, chunk)
|
||||||
|
if i < dynamic_chunks.len() - 1 { pagebreak() }
|
||||||
|
}
|
||||||
|
}
|
||||||
356
test/mv/membership/member_export_build_test.exs
Normal file
356
test/mv/membership/member_export_build_test.exs
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
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
|
||||||
265
test/mv/membership/members_pdf_test.exs
Normal file
265
test/mv/membership/members_pdf_test.exs
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
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
|
||||||
|
|
@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "export to CSV" do
|
describe "export dropdown" do
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -535,34 +535,135 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
%{member1: m1}
|
%{member1: m1}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "export button is rendered when no selection and shows (all)", %{conn: conn} do
|
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Dropdown button should be present
|
||||||
|
assert html =~ ~s(data-testid="export-dropdown")
|
||||||
|
assert html =~ ~s(data-testid="export-dropdown-button")
|
||||||
|
assert html =~ "Export"
|
||||||
# Button text shows "all" when 0 selected (locale-dependent)
|
# Button text shows "all" when 0 selected (locale-dependent)
|
||||||
assert html =~ "Export to CSV"
|
|
||||||
assert html =~ "all" or html =~ "All"
|
assert html =~ "all" or html =~ "All"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do
|
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
render_click(view, "select_member", %{"id" => member1.id})
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "Export to CSV"
|
assert html =~ "Export"
|
||||||
assert html =~ "(1)"
|
assert html =~ "(1)"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "form has correct action and payload hidden input", %{conn: conn} do
|
test "dropdown opens and closes on click", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Initially closed
|
||||||
|
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||||
|
|
||||||
|
# Click to open
|
||||||
|
view
|
||||||
|
|> element(~s([data-testid="export-dropdown-button"]))
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
# Menu should be visible
|
||||||
|
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||||
|
|
||||||
|
# Click to close
|
||||||
|
view
|
||||||
|
|> element(~s([data-testid="export-dropdown-button"]))
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
# Menu should be hidden
|
||||||
|
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "dropdown has click-away and ESC handlers", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Open dropdown
|
||||||
|
view
|
||||||
|
|> element(~s([data-testid="export-dropdown-button"]))
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||||
|
|
||||||
|
# Check that click-away handler is present
|
||||||
|
assert html =~ ~s(phx-click-away="close_dropdown")
|
||||||
|
# Check that ESC handler is present
|
||||||
|
assert html =~ ~s(phx-window-keydown="close_dropdown")
|
||||||
|
assert html =~ ~s(phx-key="Escape")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Open dropdown
|
||||||
|
render_click(view, "toggle_dropdown", %{}, "export-dropdown")
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
|
||||||
|
# Check CSV link
|
||||||
|
assert html =~ ~s(data-testid="export-csv-link")
|
||||||
assert html =~ "/members/export.csv"
|
assert html =~ "/members/export.csv"
|
||||||
assert html =~ ~s(name="payload")
|
assert html =~ ~s(name="payload")
|
||||||
assert html =~ ~s(type="hidden")
|
assert html =~ ~s(type="hidden")
|
||||||
assert html =~ ~s(name="_csrf_token")
|
assert html =~ ~s(name="_csrf_token")
|
||||||
|
|
||||||
|
# Check PDF link
|
||||||
|
assert html =~ ~s(data-testid="export-pdf-link")
|
||||||
|
assert html =~ "/members/export.pdf"
|
||||||
|
assert html =~ ~s(name="payload")
|
||||||
|
assert html =~ ~s(type="hidden")
|
||||||
|
assert html =~ ~s(name="_csrf_token")
|
||||||
|
|
||||||
|
# Both forms should have the same payload
|
||||||
|
csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
|
||||||
|
pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
|
||||||
|
|
||||||
|
assert csv_form_payload == pdf_form_payload
|
||||||
|
assert csv_form_payload != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "dropdown has correct ARIA attributes", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
|
||||||
|
# Button should have aria-haspopup="menu"
|
||||||
|
assert html =~ ~s(aria-haspopup="menu")
|
||||||
|
# Button should have aria-expanded="false" when closed
|
||||||
|
assert html =~ ~s(aria-expanded="false")
|
||||||
|
# Button should have aria-controls pointing to menu
|
||||||
|
assert html =~ ~s(aria-controls="export-dropdown-menu")
|
||||||
|
|
||||||
|
# Open dropdown
|
||||||
|
render_click(view, "toggle_dropdown", %{}, "export-dropdown")
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
# Button should have aria-expanded="true" when open
|
||||||
|
assert html =~ ~s(aria-expanded="true")
|
||||||
|
# Menu should have role="menu"
|
||||||
|
assert html =~ ~s(role="menu")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to extract payload value from form HTML
|
||||||
|
defp extract_payload_from_form(html, action_path) do
|
||||||
|
case Regex.run(
|
||||||
|
~r/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
|
||||||
|
html
|
||||||
|
) do
|
||||||
|
[_, payload] -> payload
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue