Compare commits
11 commits
b6d4425fa5
...
7a0dff926a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0dff926a | ||
| 1b671ea41a | |||
| 2bc5fcec5a | |||
| 45c9b81983 | |||
| 118b9f8d57 | |||
| 68a1a9530a | |||
| a93dd9d535 | |||
| 00e1624ee4 | |||
| a4a34cab3a | |||
| 95c7bf7a15 | |||
| 5c5fd56749 |
23 changed files with 2591 additions and 139 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
|
||||
- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
|
||||
- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
|
||||
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
|
||||
|
||||
### Fixed
|
||||
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
|
||||
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
|
||||
|
||||
## [1.2.0] - 2026-05-08
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
|
|||
|
||||
Same logic as the member overview Formatter but without Gettext or web helpers,
|
||||
so it can be used from the Membership context. For boolean: "Yes"/"No";
|
||||
for date: European format (dd.mm.yyyy).
|
||||
for date: ISO-8601 (YYYY-MM-DD) so exported values can be re-imported.
|
||||
"""
|
||||
@doc """
|
||||
Formats a custom field value for plain text (e.g. CSV).
|
||||
|
||||
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
|
||||
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
|
||||
for typing. Boolean -> "Yes"/"No", Date -> ISO-8601 (YYYY-MM-DD).
|
||||
"""
|
||||
def format_custom_field_value(nil, _custom_field), do: ""
|
||||
|
||||
|
|
@ -18,6 +18,10 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
|
|||
format_value_by_type(value, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(%Date{} = value, custom_field) do
|
||||
format_value_by_type(value, :date, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||
|
|
@ -41,12 +45,12 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
|
|||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(%Date{} = date, :date, _) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
Date.to_iso8601(date)
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
|
||||
{:ok, date} -> Date.to_iso8601(date)
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
|
|
|||
258
lib/mv/membership/import/column_resolver.ex
Normal file
258
lib/mv/membership/import/column_resolver.ex
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
defmodule Mv.Membership.Import.ColumnResolver do
|
||||
@moduledoc """
|
||||
Read-only resolution of CSV import columns against the database.
|
||||
|
||||
Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an
|
||||
actor, `resolve/3` determines:
|
||||
|
||||
- which group names in the groups column already exist (`groups_found`) and
|
||||
which would have to be created (`groups_to_create`);
|
||||
- a small set of preview rows for the mapping preview UI.
|
||||
|
||||
No database writes happen here; the resolver only reads. Group creation and
|
||||
member-group assignment happen during processing via `create_or_find_group/3`.
|
||||
|
||||
This module has no Phoenix or web dependencies.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
@preview_row_limit 3
|
||||
|
||||
@type numbered_row :: {pos_integer(), [String.t()]}
|
||||
|
||||
@type resolution :: %{
|
||||
groups_found: [%{id: String.t(), name: String.t()}],
|
||||
groups_to_create: [String.t()],
|
||||
fee_type_map: %{String.t() => String.t()},
|
||||
fee_type_warnings: [String.t()],
|
||||
has_empty_fee_type_cells?: boolean(),
|
||||
preview_rows: [[String.t()]]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Resolves the group and fee-type columns of an import against the database and
|
||||
extracts preview rows.
|
||||
|
||||
Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`,
|
||||
`:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`.
|
||||
"""
|
||||
@spec resolve(map(), [numbered_row()], term()) :: resolution()
|
||||
def resolve(header_maps, rows, actor) do
|
||||
%{
|
||||
groups_found: groups_found,
|
||||
groups_to_create: groups_to_create
|
||||
} = resolve_groups(header_maps, rows, actor)
|
||||
|
||||
%{
|
||||
fee_type_map: fee_type_map,
|
||||
fee_type_warnings: fee_type_warnings,
|
||||
has_empty_fee_type_cells?: has_empty_fee_type_cells?
|
||||
} = resolve_fee_types(header_maps, rows, actor)
|
||||
|
||||
%{
|
||||
groups_found: groups_found,
|
||||
groups_to_create: groups_to_create,
|
||||
fee_type_map: fee_type_map,
|
||||
fee_type_warnings: fee_type_warnings,
|
||||
has_empty_fee_type_cells?: has_empty_fee_type_cells?,
|
||||
preview_rows: preview_rows(rows)
|
||||
}
|
||||
end
|
||||
|
||||
defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do
|
||||
%{groups_found: [], groups_to_create: []}
|
||||
end
|
||||
|
||||
defp resolve_groups(%{groups_column_index: index}, rows, actor) do
|
||||
existing_groups = list_groups(actor)
|
||||
lookup = build_group_lookup(existing_groups)
|
||||
|
||||
names = unique_group_names(rows, index)
|
||||
|
||||
{found, to_create} =
|
||||
Enum.reduce(names, {[], []}, fn name, {found, to_create} ->
|
||||
case Map.get(lookup, normalize_name(name)) do
|
||||
nil -> {found, [name | to_create]}
|
||||
group -> {[%{id: group.id, name: group.name} | found], to_create}
|
||||
end
|
||||
end)
|
||||
|
||||
%{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)}
|
||||
end
|
||||
|
||||
defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do
|
||||
%{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false}
|
||||
end
|
||||
|
||||
defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do
|
||||
lookup = build_fee_type_lookup(actor)
|
||||
|
||||
cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end)
|
||||
|
||||
has_empty? = Enum.any?(cells, &blank?/1)
|
||||
|
||||
{fee_type_map, warnings} =
|
||||
cells
|
||||
|> Enum.reject(&blank?/1)
|
||||
|> Enum.uniq_by(&normalize_fee_type_name/1)
|
||||
|> Enum.reduce({%{}, []}, fn name, {map, warnings} ->
|
||||
case Map.get(lookup, normalize_fee_type_name(name)) do
|
||||
nil -> {map, [String.trim(name) | warnings]}
|
||||
id -> {Map.put(map, normalize_fee_type_name(name), id), warnings}
|
||||
end
|
||||
end)
|
||||
|
||||
%{
|
||||
fee_type_map: fee_type_map,
|
||||
fee_type_warnings: Enum.reverse(warnings),
|
||||
has_empty_fee_type_cells?: has_empty?
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a fee-type name using the same rules as CSV header normalization
|
||||
(trim, lowercase, transliterate, drop hyphens and whitespace).
|
||||
"""
|
||||
@spec normalize_fee_type_name(String.t() | nil) :: String.t()
|
||||
def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name)
|
||||
def normalize_fee_type_name(_), do: ""
|
||||
|
||||
defp build_fee_type_lookup(actor) do
|
||||
actor
|
||||
|> list_fee_types()
|
||||
|> Enum.reduce(%{}, fn fee_type, acc ->
|
||||
normalized = normalize_fee_type_name(fee_type.name)
|
||||
|
||||
if Map.has_key?(acc, normalized) do
|
||||
Logger.warning(
|
||||
"Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import."
|
||||
)
|
||||
|
||||
acc
|
||||
else
|
||||
Map.put(acc, normalized, fee_type.id)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp list_fee_types(actor) do
|
||||
Mv.MembershipFees.list_membership_fee_types!(actor: actor)
|
||||
end
|
||||
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
defp blank?(_), do: false
|
||||
|
||||
@doc """
|
||||
Finds an existing group by name (case-insensitive) or creates it.
|
||||
|
||||
Looks first in the pre-fetched `groups` list, then in the database (to catch
|
||||
groups created earlier in the same import), and only creates a new group when
|
||||
none is found. This keeps group resolution idempotent across re-imports.
|
||||
"""
|
||||
@spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) ::
|
||||
{:ok, Mv.Membership.Group.t()} | {:error, term()}
|
||||
def create_or_find_group(name, groups, actor) when is_binary(name) do
|
||||
trimmed = String.trim(name)
|
||||
normalized = normalize_name(trimmed)
|
||||
|
||||
case find_group_in_list(groups, normalized) do
|
||||
nil -> find_or_create_group(trimmed, normalized, actor)
|
||||
group -> {:ok, group}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_group_in_list(groups, normalized) do
|
||||
Enum.find(groups, fn group -> normalize_name(group.name) == normalized end)
|
||||
end
|
||||
|
||||
defp find_or_create_group(trimmed, normalized, actor) do
|
||||
case fetch_group_by_normalized_name(normalized, actor) do
|
||||
nil -> create_group(trimmed, normalized, actor)
|
||||
group -> {:ok, group}
|
||||
end
|
||||
end
|
||||
|
||||
# Normalizes the Ash code-interface return to a two-shape result.
|
||||
#
|
||||
# On a create failure the group may have been created concurrently by another
|
||||
# import session between our read and our write (the DB unique index is the
|
||||
# final arbiter, and the name validation is fail-open). Re-fetch by normalized
|
||||
# name and link to the existing group rather than failing the row.
|
||||
defp create_group(name, normalized, actor) do
|
||||
case Mv.Membership.create_group(%{name: name}, actor: actor) do
|
||||
{:ok, %Mv.Membership.Group{} = group} ->
|
||||
{:ok, group}
|
||||
|
||||
{:error, reason} ->
|
||||
case fetch_group_by_normalized_name(normalized, actor) do
|
||||
nil -> {:error, reason}
|
||||
group -> {:ok, group}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches a single group by case-insensitive name using a name-filtered query
|
||||
# rather than reading the whole groups table. `normalized` is the trimmed,
|
||||
# lower-cased name; the DB comparison uses LOWER(name) consistent with the
|
||||
# Group resource's case-insensitive uniqueness constraint.
|
||||
defp fetch_group_by_normalized_name(normalized, actor) do
|
||||
require Ash.Query
|
||||
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized))
|
||||
|> Ash.read(actor: actor, domain: Mv.Membership)
|
||||
|> case do
|
||||
{:ok, [group | _]} -> group
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Splits a raw groups-cell value into trimmed, non-empty group names.
|
||||
"""
|
||||
@spec split_group_names(String.t() | nil) :: [String.t()]
|
||||
def split_group_names(nil), do: []
|
||||
|
||||
def split_group_names(cell) when is_binary(cell) do
|
||||
cell
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
|
||||
defp unique_group_names(rows, index) do
|
||||
rows
|
||||
|> Enum.flat_map(fn {_line, values} ->
|
||||
values
|
||||
|> Enum.at(index)
|
||||
|> split_group_names()
|
||||
end)
|
||||
|> Enum.uniq_by(&normalize_name/1)
|
||||
end
|
||||
|
||||
defp preview_rows(rows) do
|
||||
rows
|
||||
|> Enum.take(@preview_row_limit)
|
||||
|> Enum.map(fn {_line, values} -> values end)
|
||||
end
|
||||
|
||||
defp list_groups(actor) do
|
||||
Mv.Membership.list_groups!(actor: actor)
|
||||
end
|
||||
|
||||
defp build_group_lookup(groups) do
|
||||
Enum.reduce(groups, %{}, fn group, acc ->
|
||||
Map.put(acc, normalize_name(group.name), group)
|
||||
end)
|
||||
end
|
||||
|
||||
# Case-insensitive comparison consistent with the Group resource's
|
||||
# case-insensitive name uniqueness.
|
||||
defp normalize_name(name) when is_binary(name) do
|
||||
name |> String.trim() |> String.downcase()
|
||||
end
|
||||
end
|
||||
|
|
@ -29,12 +29,21 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
|
||||
|
||||
## Special columns
|
||||
|
||||
- **groups** – Many-to-many relationship (through member_groups). Recognized via the
|
||||
`groups_column_index` key (headers `Groups`, `Gruppen`, `Gruppe`). Comma-separated
|
||||
names are resolved during processing; missing groups are auto-created.
|
||||
- **membership_fee_type** – Recognized via the `fee_type_column_index` key (headers
|
||||
`Fee Type`, `fee_type`, `membership_fee_type`, `Beitragsart`). Names are matched to
|
||||
existing fee types; unknown names fall back to the default fee type.
|
||||
|
||||
## Fields not supported for import
|
||||
|
||||
- **membership_fee_status** – Computed (calculation from membership fee cycles). Not stored;
|
||||
cannot be set via CSV. Export can include it.
|
||||
- **groups** – Many-to-many relationship (through member_groups). Import would require
|
||||
resolving group names/slugs to IDs and creating associations; not in current import scope.
|
||||
cannot be set via CSV. Export can include it. Fee-status header variants
|
||||
(`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) are explicitly
|
||||
placed in the `ignored` list and never mapped.
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
|
|
@ -47,10 +56,10 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"e-mail"
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
|
||||
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
"""
|
||||
|
||||
@type column_map :: %{atom() => non_neg_integer()}
|
||||
|
|
@ -60,6 +69,33 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
# Required member fields
|
||||
@required_member_fields [:email]
|
||||
|
||||
# Fee-status header variants that must never be imported (computed/read-only field).
|
||||
# Stored already-normalized; checked before member, custom, groups, and fee-type mapping.
|
||||
# Maintain this list when new locale translations for fee-status are added.
|
||||
@ignored_normalized [
|
||||
"membershipfeestatus",
|
||||
"mitgliedsbeitragsstatus",
|
||||
"bezahlstatus",
|
||||
# DE export label for membership_fee_start_date — system-managed, not importable
|
||||
"startdatummitgliedsbeitrag"
|
||||
]
|
||||
|
||||
# Normalized header variants for the groups column. The column is resolved to
|
||||
# group associations during import; it is never a member or custom field.
|
||||
@groups_column_normalized [
|
||||
"groups",
|
||||
"gruppen",
|
||||
"gruppe"
|
||||
]
|
||||
|
||||
# Normalized header variants for the membership fee-type column. The column is
|
||||
# resolved to a MembershipFeeType during import; it is never a member or custom field.
|
||||
@fee_type_column_normalized [
|
||||
"membershipfeetype",
|
||||
"feetype",
|
||||
"beitragsart"
|
||||
]
|
||||
|
||||
# Canonical member fields with their raw variants
|
||||
# These will be normalized at runtime when building the lookup map
|
||||
@member_field_variants_raw %{
|
||||
|
|
@ -239,30 +275,79 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
## Returns
|
||||
|
||||
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success
|
||||
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers,
|
||||
ignored: [non_neg_integer], groups_column_index: non_neg_integer | nil,
|
||||
fee_type_column_index: non_neg_integer | nil}}` on success
|
||||
- `{:error, reason}` on error (missing required field, duplicate headers)
|
||||
|
||||
The `ignored` list holds the indices of fee-status columns (computed/read-only),
|
||||
which are never mapped to member or custom fields.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> build_maps(["Email", "First Name"], [])
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
|
||||
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
|
||||
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
|
||||
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
|
||||
|
||||
"""
|
||||
@spec build_maps([String.t()], [map()]) ::
|
||||
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}}
|
||||
{:ok,
|
||||
%{
|
||||
member: column_map(),
|
||||
custom: custom_field_map(),
|
||||
unknown: unknown_headers(),
|
||||
ignored: [non_neg_integer()],
|
||||
groups_column_index: non_neg_integer() | nil,
|
||||
fee_type_column_index: non_neg_integer() | nil
|
||||
}}
|
||||
| {:error, String.t()}
|
||||
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
|
||||
with {:ok, member_map, unknown_after_member} <- build_member_map(headers),
|
||||
ignored = ignored_indices(headers)
|
||||
groups_column_index = first_matching_index(headers, @groups_column_normalized)
|
||||
fee_type_column_index = first_matching_index(headers, @fee_type_column_normalized)
|
||||
|
||||
reserved =
|
||||
[groups_column_index, fee_type_column_index | ignored]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> MapSet.new()
|
||||
|
||||
with {:ok, member_map, unknown_after_member} <- build_member_map(headers, reserved),
|
||||
{:ok, custom_map, unknown_after_custom} <-
|
||||
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
|
||||
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
member: member_map,
|
||||
custom: custom_map,
|
||||
unknown: unknown,
|
||||
ignored: ignored,
|
||||
groups_column_index: groups_column_index,
|
||||
fee_type_column_index: fee_type_column_index
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the index of the first header whose normalized form is in `variants`,
|
||||
# or nil if none match.
|
||||
defp first_matching_index(headers, variants) do
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.find_value(fn {header, index} ->
|
||||
if normalize_header(header) in variants, do: index
|
||||
end)
|
||||
end
|
||||
|
||||
# Returns the column indices whose normalized header is in the fee-status ignore list.
|
||||
defp ignored_indices(headers) do
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.filter(fn {header, _index} -> normalize_header(header) in @ignored_normalized end)
|
||||
|> Enum.map(fn {_header, index} -> index end)
|
||||
end
|
||||
|
||||
# --- Private Functions ---
|
||||
|
||||
# Transliterates German umlauts and special characters
|
||||
|
|
@ -304,13 +389,14 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|> String.replace(" ", "")
|
||||
end
|
||||
|
||||
# Builds member field column map
|
||||
defp build_member_map(headers) do
|
||||
# Builds member field column map, skipping reserved (e.g. ignored) indices.
|
||||
defp build_member_map(headers, reserved) do
|
||||
result =
|
||||
headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
|
||||
normalized = normalize_header(header)
|
||||
normalized =
|
||||
if MapSet.member?(reserved, index), do: "", else: normalize_header(header)
|
||||
|
||||
case process_member_header(header, index, normalized, acc_map, %{}) do
|
||||
{:error, reason} ->
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ defmodule Mv.Membership.Import.ImportRunner do
|
|||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, max_errors)
|
||||
errors_truncated? = length(all_errors) > max_errors
|
||||
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
|
||||
new_warnings = Enum.uniq(progress.warnings ++ Map.get(chunk_result, :warnings, []))
|
||||
|
||||
chunks_processed = current_chunk_idx + 1
|
||||
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
|
||||
|
|
@ -97,6 +97,20 @@ defmodule Mv.Membership.Import.ImportRunner do
|
|||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Carries the in-memory group snapshot grown by a chunk back into `import_state`
|
||||
so the next chunk reuses groups created earlier instead of re-reading the
|
||||
Group table. When the chunk result omits `groups_found`, the state is returned
|
||||
unchanged.
|
||||
"""
|
||||
@spec carry_groups_forward(map(), map()) :: map()
|
||||
def carry_groups_forward(import_state, chunk_result) do
|
||||
case Map.fetch(chunk_result, :groups_found) do
|
||||
{:ok, groups_found} -> Map.put(import_state, :groups_found, groups_found)
|
||||
:error -> import_state
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the next action after processing a chunk: send the next chunk index or done.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
This module provides the core API for CSV member import functionality:
|
||||
- `prepare/2` - Parses and validates CSV content, returns import state
|
||||
- `process_chunk/3` - Processes a chunk of rows and creates members
|
||||
- `process_chunk/4` - Processes a chunk of rows and creates members
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
|
@ -22,13 +22,24 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
- `column_map` - Map of canonical field names to column indices
|
||||
- `custom_field_map` - Map of custom field names to column indices
|
||||
- `warnings` - List of warning messages (e.g., unknown custom field columns)
|
||||
- `headers` - The raw CSV header row
|
||||
- `ignored` - Header names of ignored (fee-status) columns
|
||||
- `groups_column_index` / `fee_type_column_index` - Indices for resolved columns (or nil)
|
||||
- `groups_found` / `groups_to_create` - Existing and to-be-created groups from the preview
|
||||
- `fee_type_map` - Normalized fee-type name to id, for matched fee types
|
||||
- `fee_type_warnings` - Unmatched fee-type names surfaced in the preview
|
||||
- `has_empty_fee_type_cells?` - Whether any fee-type cell is blank (default applies)
|
||||
- `preview_rows` - Up to 3 sample data rows for the mapping preview
|
||||
|
||||
## Chunk Results
|
||||
|
||||
The `chunk_result` returned by `process_chunk/3` contains:
|
||||
The `chunk_result` returned by `process_chunk/4` contains:
|
||||
- `inserted` - Number of successfully created members
|
||||
- `failed` - Number of failed member creations
|
||||
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
|
||||
- `groups_found` - The in-memory group snapshot grown while processing this
|
||||
chunk; thread it into the next chunk's `:groups_found` opt so groups created
|
||||
in an earlier chunk are reused without re-reading the Group table
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
@ -37,7 +48,9 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
# Process first chunk
|
||||
chunk = Enum.at(import_state.chunks, 0)
|
||||
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
|
||||
|
||||
{:ok, result} =
|
||||
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
|
||||
"""
|
||||
|
||||
defmodule Error do
|
||||
|
|
@ -66,16 +79,29 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
custom_field_lookup: %{
|
||||
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
|
||||
},
|
||||
warnings: list(String.t())
|
||||
warnings: list(String.t()),
|
||||
headers: list(String.t()),
|
||||
ignored: list(String.t()),
|
||||
groups_column_index: non_neg_integer() | nil,
|
||||
fee_type_column_index: non_neg_integer() | nil,
|
||||
groups_found: list(%{id: String.t(), name: String.t()}),
|
||||
groups_to_create: list(String.t()),
|
||||
fee_type_map: %{String.t() => String.t()},
|
||||
fee_type_warnings: list(String.t()),
|
||||
has_empty_fee_type_cells?: boolean(),
|
||||
preview_rows: list(list(String.t()))
|
||||
}
|
||||
|
||||
@type chunk_result :: %{
|
||||
inserted: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
errors: list(Error.t()),
|
||||
errors_truncated?: boolean()
|
||||
errors_truncated?: boolean(),
|
||||
warnings: list(String.t()),
|
||||
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
|
||||
}
|
||||
|
||||
alias Mv.Membership.Import.ColumnResolver
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
|
|
@ -139,13 +165,27 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
# Build custom field lookup for efficient value processing
|
||||
custom_field_lookup = build_custom_field_lookup(custom_fields)
|
||||
|
||||
# Resolve DB-backed columns (groups, fee types) read-only for the preview.
|
||||
resolution = ColumnResolver.resolve(maps, rows, actor)
|
||||
ignored_headers = Enum.map(maps.ignored, &Enum.at(headers, &1))
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
chunks: chunks,
|
||||
column_map: maps.member,
|
||||
custom_field_map: maps.custom,
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
warnings: warnings
|
||||
warnings: warnings,
|
||||
headers: headers,
|
||||
ignored: ignored_headers,
|
||||
groups_column_index: maps.groups_column_index,
|
||||
fee_type_column_index: maps.fee_type_column_index,
|
||||
groups_found: resolution.groups_found,
|
||||
groups_to_create: resolution.groups_to_create,
|
||||
fee_type_map: resolution.fee_type_map,
|
||||
fee_type_warnings: resolution.fee_type_warnings,
|
||||
has_empty_fee_type_cells?: resolution.has_empty_fee_type_cells?,
|
||||
preview_rows: resolution.preview_rows
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
|
@ -180,7 +220,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end)
|
||||
|
||||
case HeaderMapper.build_maps(headers, custom_field_maps) do
|
||||
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}} ->
|
||||
{:ok, %{unknown: unknown} = maps} ->
|
||||
# Build warnings for unknown custom field columns
|
||||
warnings =
|
||||
unknown
|
||||
|
|
@ -197,7 +237,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
)
|
||||
end)
|
||||
|
||||
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
||||
{:ok, maps, warnings}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
|
@ -250,9 +290,20 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
Map.put(acc, custom_field_id, value)
|
||||
end)
|
||||
|
||||
%{member: member_map, custom: custom_map}
|
||||
%{
|
||||
member: member_map,
|
||||
custom: custom_map,
|
||||
fee_type: cell_at(row_tuple, tuple_size, maps.fee_type_column_index),
|
||||
groups: cell_at(row_tuple, tuple_size, maps.groups_column_index)
|
||||
}
|
||||
end
|
||||
|
||||
# Returns the raw cell at the given index, or nil if the column is absent.
|
||||
defp cell_at(_row_tuple, _size, nil), do: nil
|
||||
|
||||
defp cell_at(row_tuple, size, index) when index < size, do: elem(row_tuple, index)
|
||||
defp cell_at(_row_tuple, _size, _index), do: ""
|
||||
|
||||
@doc """
|
||||
Processes a chunk of CSV rows and creates members.
|
||||
|
||||
|
|
@ -268,12 +319,18 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
|
||||
- `csv_line_number` - Physical line number in CSV (1-based)
|
||||
- `row_map` - Map with `:member` and `:custom` keys containing field values
|
||||
- `column_map` - Map of canonical field names (atoms) to column indices (for reference)
|
||||
- `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference)
|
||||
- `column_map` - Unused; kept for backward-compatible call sites. Field values are
|
||||
read from each row's pre-built `:member`/`:custom` maps, not from this argument.
|
||||
- `custom_field_map` - Unused; kept for backward-compatible call sites (see above).
|
||||
- `opts` - Optional keyword list for processing options:
|
||||
- `:custom_field_lookup` - Map of custom field IDs to metadata (default: `%{}`)
|
||||
- `:existing_error_count` - Number of errors already collected in previous chunks (default: `0`)
|
||||
- `:max_errors` - Maximum number of errors to collect per import overall (default: `50`)
|
||||
- `:actor` - Actor used for all writes (default: the system actor)
|
||||
- `:fee_type_map` - Map of normalized fee-type name to fee-type id, used to resolve
|
||||
each row's fee-type cell (default: `%{}`)
|
||||
- `:groups_found` - List of pre-fetched `Group` structs seeding in-memory group
|
||||
resolution; the snapshot grows as groups are auto-created (default: `[]`)
|
||||
|
||||
## Error Capping
|
||||
|
||||
|
|
@ -312,27 +369,49 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
|
||||
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
||||
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
|
||||
fee_type_map = Keyword.get(opts, :fee_type_map, %{})
|
||||
groups_found = Keyword.get(opts, :groups_found, [])
|
||||
|
||||
{inserted, failed, errors, _collected_error_count, truncated?} =
|
||||
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
|
||||
{acc_inserted, acc_failed,
|
||||
acc_errors, acc_error_count,
|
||||
acc_truncated?} ->
|
||||
base_row_opts = %{
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
fee_type_map: fee_type_map,
|
||||
actor: actor
|
||||
}
|
||||
|
||||
{inserted, failed, errors, _collected_error_count, truncated?, warnings, groups_acc} =
|
||||
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number,
|
||||
row_map},
|
||||
{acc_inserted,
|
||||
acc_failed,
|
||||
acc_errors,
|
||||
acc_error_count,
|
||||
acc_truncated?,
|
||||
acc_warnings,
|
||||
acc_groups} ->
|
||||
current_error_count = existing_error_count + acc_error_count
|
||||
row_opts = Map.put(base_row_opts, :groups_found, acc_groups)
|
||||
|
||||
case process_row(row_map, line_number, custom_field_lookup, actor) do
|
||||
{:ok, _member} ->
|
||||
case process_row(row_map, line_number, row_opts) do
|
||||
{:ok, _member, row_warnings, new_groups} ->
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
|
||||
update_inserted(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?,
|
||||
acc_warnings ++ row_warnings, new_groups}
|
||||
|
||||
{:error, error, new_groups} ->
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
|
||||
handle_row_error(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
|
||||
error,
|
||||
current_error_count,
|
||||
max_errors
|
||||
)
|
||||
|
||||
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?, acc_warnings,
|
||||
new_groups}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -341,7 +420,9 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
inserted: inserted,
|
||||
failed: failed,
|
||||
errors: Enum.reverse(errors),
|
||||
errors_truncated?: truncated?
|
||||
errors_truncated?: truncated?,
|
||||
warnings: warnings,
|
||||
groups_found: groups_acc
|
||||
}}
|
||||
end
|
||||
|
||||
|
|
@ -505,18 +586,27 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
defp gettext_error_message(_), do: gettext("Email is invalid.")
|
||||
|
||||
# Processes a single row and creates member with custom field values
|
||||
# Processes a single row and creates member with custom field values.
|
||||
# On success returns {:ok, member, warnings, groups}; warnings carry non-fatal
|
||||
# notices such as an unresolved fee-type name. The returned groups list is the
|
||||
# accumulated in-memory group snapshot (seeded from the chunk, grown with any
|
||||
# group created while linking this row) so later rows reuse it instead of
|
||||
# re-reading the whole Group table per row.
|
||||
defp process_row(
|
||||
row_map,
|
||||
line_number,
|
||||
custom_field_lookup,
|
||||
actor
|
||||
%{
|
||||
custom_field_lookup: custom_field_lookup,
|
||||
fee_type_map: fee_type_map,
|
||||
groups_found: groups_found,
|
||||
actor: actor
|
||||
} = _row_opts
|
||||
) do
|
||||
# Validate row before database insertion
|
||||
case validate_row(row_map, line_number, []) do
|
||||
{:error, error} ->
|
||||
# Return validation error immediately, no DB insert attempted
|
||||
{:error, error}
|
||||
{:error, error, groups_found}
|
||||
|
||||
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
|
||||
# Prepare custom field values for Ash
|
||||
|
|
@ -524,20 +614,119 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
{:error, validation_errors} ->
|
||||
# Custom field validation errors - return first error
|
||||
first_error = List.first(validation_errors)
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
|
||||
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error},
|
||||
groups_found}
|
||||
|
||||
{:ok, custom_field_values} ->
|
||||
create_member_with_custom_fields(
|
||||
trimmed_member_attrs,
|
||||
{fee_attrs, warnings} =
|
||||
resolve_fee_type_attrs(Map.get(row_map, :fee_type), fee_type_map)
|
||||
|
||||
create_member_and_assign_groups(
|
||||
Map.merge(trimmed_member_attrs, fee_attrs),
|
||||
custom_field_values,
|
||||
Map.get(row_map, :groups),
|
||||
groups_found,
|
||||
line_number,
|
||||
actor
|
||||
actor,
|
||||
warnings
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)},
|
||||
groups_found}
|
||||
end
|
||||
|
||||
# Creates the member, then assigns groups as a post-creation step. A group
|
||||
# assignment failure fails the row (the member was already created, but the
|
||||
# row is reported as failed so the operator can act on it).
|
||||
defp create_member_and_assign_groups(
|
||||
member_attrs,
|
||||
custom_field_values,
|
||||
groups_cell,
|
||||
groups_found,
|
||||
line_number,
|
||||
actor,
|
||||
warnings
|
||||
) do
|
||||
case create_member_with_custom_fields(
|
||||
member_attrs,
|
||||
custom_field_values,
|
||||
line_number,
|
||||
actor,
|
||||
warnings
|
||||
) do
|
||||
{:ok, member, member_warnings} ->
|
||||
assign_groups(member, groups_cell, groups_found, line_number, actor, member_warnings)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error, groups_found}
|
||||
end
|
||||
end
|
||||
|
||||
# Assigns the member to all groups listed in the cell, creating missing groups.
|
||||
# Returns the (possibly grown) group snapshot so the caller can reuse it.
|
||||
defp assign_groups(member, groups_cell, groups_found, line_number, actor, warnings) do
|
||||
names = ColumnResolver.split_group_names(groups_cell)
|
||||
|
||||
Enum.reduce_while(names, {:ok, member, warnings, groups_found}, fn name,
|
||||
{:ok, _m, _w, acc_groups} ->
|
||||
case link_member_to_group(member, name, acc_groups, actor) do
|
||||
{:ok, group} ->
|
||||
{:cont, {:ok, member, warnings, add_group(acc_groups, group)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:halt,
|
||||
{:error,
|
||||
%Error{
|
||||
csv_line_number: line_number,
|
||||
field: nil,
|
||||
message: gettext("Group assignment failed: %{reason}", reason: inspect(reason))
|
||||
}, acc_groups}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp add_group(groups, group) do
|
||||
if Enum.any?(groups, &(&1.id == group.id)), do: groups, else: [group | groups]
|
||||
end
|
||||
|
||||
defp link_member_to_group(member, name, groups_found, actor) do
|
||||
with {:ok, group} <- ColumnResolver.create_or_find_group(name, groups_found, actor),
|
||||
{:ok, _member_group} <-
|
||||
Mv.Membership.create_member_group(
|
||||
%{member_id: member.id, group_id: group.id},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, group}
|
||||
end
|
||||
end
|
||||
|
||||
# Resolves the fee-type cell into member attrs plus optional warnings.
|
||||
# Empty cell -> default fee type (SetDefaultMembershipFeeType), no warning.
|
||||
# Matched name -> membership_fee_type_id attr.
|
||||
# Unmatched name -> no attr (default applies), warning naming the value.
|
||||
defp resolve_fee_type_attrs(nil, _fee_type_map), do: {%{}, []}
|
||||
|
||||
defp resolve_fee_type_attrs(cell, fee_type_map) when is_binary(cell) do
|
||||
trimmed = String.trim(cell)
|
||||
|
||||
if trimmed == "" do
|
||||
{%{}, []}
|
||||
else
|
||||
case Map.get(fee_type_map, ColumnResolver.normalize_fee_type_name(trimmed)) do
|
||||
nil ->
|
||||
{%{},
|
||||
[
|
||||
gettext("Fee type '%{name}' not found; using the default fee type.", name: trimmed)
|
||||
]}
|
||||
|
||||
fee_type_id ->
|
||||
{%{membership_fee_type_id: fee_type_id}, []}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Creates a member with custom field values, handling errors appropriately
|
||||
|
|
@ -545,7 +734,8 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
trimmed_member_attrs,
|
||||
custom_field_values,
|
||||
line_number,
|
||||
actor
|
||||
actor,
|
||||
warnings
|
||||
) do
|
||||
# Convert empty strings to nil for date fields so Ash accepts them
|
||||
member_attrs = sanitize_date_fields(trimmed_member_attrs)
|
||||
|
|
@ -565,7 +755,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
case Mv.Membership.create_member(final_attrs, actor: actor) do
|
||||
{:ok, member} ->
|
||||
{:ok, member}
|
||||
{:ok, member, warnings}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
# Extract email from final_attrs for better error messages
|
||||
|
|
|
|||
120
lib/mv_web/controllers/import_template_controller.ex
Normal file
120
lib/mv_web/controllers/import_template_controller.ex
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
defmodule MvWeb.ImportTemplateController do
|
||||
@moduledoc """
|
||||
Serves CSV import templates generated on the fly from the current custom fields.
|
||||
|
||||
Two actions provide an English (`en/2`) and a German (`de/2`) template. Each
|
||||
template has a single header row listing the standard member columns followed
|
||||
by every existing custom field name (exact match, as the import expects), plus
|
||||
the importable groups and fee-type columns. A single placeholder example row is
|
||||
included to illustrate the format.
|
||||
|
||||
Both actions require the same authorization as the import page
|
||||
(`can?(:create, Member)`); unauthorized requests are rejected.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MembersCSV
|
||||
alias MvWeb.Authorization
|
||||
|
||||
# Standard member columns in template order, with their English and German headers
|
||||
# and a placeholder example value. Groups and fee type are importable extras.
|
||||
@columns [
|
||||
{"first name", "Vorname", "John", "Max"},
|
||||
{"last name", "Nachname", "Doe", "Mustermann"},
|
||||
{"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"},
|
||||
{"country", "Land", "Germany", "Deutschland"},
|
||||
{"city", "Stadt", "Berlin", "Berlin"},
|
||||
{"street", "Straße", "Main Street", "Hauptstraße"},
|
||||
{"house number", "Hausnummer", "1a", "12"},
|
||||
{"postal_code", "PLZ", "12345", "10115"},
|
||||
{"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"},
|
||||
{"exit_date", "Austrittsdatum", "", ""},
|
||||
{"notes", "Notizen", "", ""},
|
||||
{"membership_fee_start_date", "Beitragsbeginn", "", ""},
|
||||
{"Groups", "Gruppen", "", ""},
|
||||
{"Fee Type", "Beitragsart", "", ""}
|
||||
]
|
||||
|
||||
@spec en(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def en(conn, _params) do
|
||||
serve_template(conn, :en, "member_import_en.csv")
|
||||
end
|
||||
|
||||
@spec de(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def de(conn, _params) do
|
||||
serve_template(conn, :de, "member_import_de.csv")
|
||||
end
|
||||
|
||||
defp serve_template(conn, locale, filename) do
|
||||
actor = current_actor(conn)
|
||||
|
||||
if Authorization.can?(actor, :create, Member) do
|
||||
csv = build_csv(locale, actor)
|
||||
|
||||
send_download(conn, {:binary, csv},
|
||||
filename: filename,
|
||||
content_type: "text/csv; charset=utf-8"
|
||||
)
|
||||
else
|
||||
return_forbidden(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_csv(locale, actor) do
|
||||
custom_field_names = custom_field_names(actor)
|
||||
|
||||
header =
|
||||
Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names
|
||||
|
||||
example =
|
||||
Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end)
|
||||
|
||||
[csv_row(header), csv_row(example)]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en
|
||||
defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de
|
||||
|
||||
defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en
|
||||
defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de
|
||||
|
||||
defp custom_field_names(actor) do
|
||||
Mv.Membership.list_custom_fields!(actor: actor)
|
||||
|> Enum.map(& &1.name)
|
||||
end
|
||||
|
||||
# Serializes a row using the semicolon delimiter (the import auto-detects it),
|
||||
# quoting any field that contains a delimiter, quote, or newline.
|
||||
defp csv_row(fields) do
|
||||
Enum.map_join(fields, ";", &escape_field/1)
|
||||
end
|
||||
|
||||
# Neutralizes spreadsheet formula triggers (the same guard the export writer
|
||||
# applies) before RFC 4180 quoting, so a custom-field name like
|
||||
# `=HYPERLINK(...)` is not evaluated when the template is opened.
|
||||
defp escape_field(field) do
|
||||
field = field |> to_string() |> MembersCSV.safe_cell()
|
||||
|
||||
if String.contains?(field, [";", "\"", "\n", "\r"]) do
|
||||
"\"" <> String.replace(field, "\"", "\"\"") <> "\""
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp return_forbidden(conn) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "Forbidden"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
|
@ -99,7 +99,12 @@ defmodule MvWeb.ImportLive do
|
|||
<.form_section title={gettext("Choose CSV file")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<%= if @import_status != :preview do %>
|
||||
<Components.import_form {assigns} />
|
||||
<% end %>
|
||||
<%= if @import_status == :preview do %>
|
||||
<Components.preview {assigns} />
|
||||
<% end %>
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
|
|
@ -133,6 +138,29 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_import", _params, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state} when is_map(import_state) ->
|
||||
start_import(socket, import_state)
|
||||
|
||||
_ ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("No prepared import to confirm. Please upload again."))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_import", _params, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, nil)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :idle)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Checks if all prerequisites for starting an import are met.
|
||||
#
|
||||
# Validates:
|
||||
|
|
@ -169,10 +197,10 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
# Processes CSV upload and starts import process.
|
||||
# Processes CSV upload and enters the mapping preview.
|
||||
#
|
||||
# Reads the uploaded CSV file, prepares it for import, and initiates
|
||||
# the chunked processing workflow.
|
||||
# Reads the uploaded CSV file and prepares it (read-only at the DB level), then
|
||||
# shows the mapping preview. No member is created until the user confirms.
|
||||
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp process_csv_upload(socket) do
|
||||
|
|
@ -181,7 +209,7 @@ defmodule MvWeb.ImportLive do
|
|||
with {:ok, content} <- consume_and_read_csv(socket),
|
||||
{:ok, import_state} <-
|
||||
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||
start_import(socket, import_state)
|
||||
enter_preview(socket, import_state)
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:noreply,
|
||||
|
|
@ -193,6 +221,19 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
# Shows the mapping preview without starting any processing.
|
||||
@spec enter_preview(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp enter_preview(socket, import_state) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :preview)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Starts the import process by initializing progress tracking and scheduling the first chunk.
|
||||
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
|
|
@ -263,7 +304,9 @@ defmodule MvWeb.ImportLive do
|
|||
custom_field_lookup: import_state.custom_field_lookup,
|
||||
existing_error_count: length(progress.errors),
|
||||
max_errors: @max_errors,
|
||||
actor: actor
|
||||
actor: actor,
|
||||
fee_type_map: import_state.fee_type_map,
|
||||
groups_found: import_state.groups_found
|
||||
]
|
||||
|
||||
_ =
|
||||
|
|
@ -324,8 +367,11 @@ defmodule MvWeb.ImportLive do
|
|||
new_progress =
|
||||
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
|
||||
|
||||
new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, new_import_state)
|
||||
|> assign(:import_progress, new_progress)
|
||||
|> assign(:import_status, new_progress.status)
|
||||
|> maybe_send_next_chunk(idx, length(import_state.chunks))
|
||||
|
|
|
|||
|
|
@ -25,7 +25,22 @@ defmodule MvWeb.ImportLive.Components do
|
|||
<div>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{gettext(
|
||||
"Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -44,20 +59,12 @@ defmodule MvWeb.ImportLive.Components do
|
|||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_en.csv"}
|
||||
download="member_import_en.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
<.link href={~p"/admin/import/template/en"} class="link link-primary">
|
||||
{gettext("English Template")}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/templates/member_import_de.csv"}
|
||||
download="member_import_de.csv"
|
||||
class="link link-primary"
|
||||
>
|
||||
<.link href={~p"/admin/import/template/de"} class="link link-primary">
|
||||
{gettext("German Template")}
|
||||
</.link>
|
||||
</li>
|
||||
|
|
@ -108,6 +115,194 @@ defmodule MvWeb.ImportLive.Components do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the mapping preview shown between upload and processing.
|
||||
|
||||
Shows the column-to-role mapping, up to 3 sample rows, and notices for
|
||||
auto-created groups, unresolved fee types, empty fee-type cells, and unknown
|
||||
columns. Nothing is written until the user confirms.
|
||||
"""
|
||||
def preview(assigns) do
|
||||
state = assigns.import_state
|
||||
column_roles = column_roles(state)
|
||||
column_samples = column_samples(state.preview_rows, length(state.headers))
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:column_roles, column_roles)
|
||||
|> assign(:column_samples, column_samples)
|
||||
|
||||
~H"""
|
||||
<section
|
||||
class="mt-4 space-y-4"
|
||||
data-testid="import-preview"
|
||||
aria-labelledby="import-preview-heading"
|
||||
>
|
||||
<h2 id="import-preview-heading" class="text-lg font-semibold">
|
||||
{gettext("Preview import")}
|
||||
</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm w-full" data-testid="preview-mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{gettext("Role")}</th>
|
||||
<th>{gettext("Column")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 1")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 2")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 3")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %>
|
||||
<tr class={role_row_class(role)} data-testid="preview-column-row">
|
||||
<td>
|
||||
<span class={"badge badge-sm #{role_badge_class(role)}"}>
|
||||
{role_label(role)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-medium">{header}</td>
|
||||
<%= for sample <- samples do %>
|
||||
<td class="text-base-content/70 max-w-32 truncate">{sample}</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%= if @import_state.groups_to_create != [] do %>
|
||||
<div class="alert alert-info" role="note" data-testid="preview-groups-notice">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("These groups will be created automatically: %{names}",
|
||||
names: Enum.join(@import_state.groups_to_create, ", ")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.fee_type_warnings != [] do %>
|
||||
<div class="alert alert-warning" role="alert" data-testid="preview-fee-type-warning">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Unknown fee types (members get the default): %{names}",
|
||||
names: Enum.join(@import_state.fee_type_warnings, ", ")
|
||||
)}
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_settings/new_fee_type"}
|
||||
class="link link-primary text-sm"
|
||||
>
|
||||
{gettext("Create fee type")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.has_empty_fee_type_cells? do %>
|
||||
<div class="alert alert-info" role="note" data-testid="preview-fee-type-info">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Rows with an empty fee type will get the default fee type.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.warnings != [] do %>
|
||||
<div class="alert alert-warning" role="alert" data-testid="preview-unknown-warning">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for warning <- @import_state.warnings do %>
|
||||
<li>{warning}</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<.link navigate={~p"/admin/datafields"} class="link link-primary text-sm">
|
||||
{gettext("Create custom field")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="confirm_import"
|
||||
variant="primary"
|
||||
data-testid="confirm-import-button"
|
||||
>
|
||||
{gettext("Confirm and Import")}
|
||||
</.button>
|
||||
<.button type="button" phx-click="cancel_import" data-testid="cancel-import-button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# Pairs each CSV header with its resolved role for the preview mapping table.
|
||||
defp column_roles(state) do
|
||||
member_indices = MapSet.new(Map.values(state.column_map))
|
||||
custom_indices = MapSet.new(Map.values(state.custom_field_map))
|
||||
ignored_headers = MapSet.new(state.ignored)
|
||||
|
||||
state.headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {header, index} ->
|
||||
{header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do
|
||||
cond do
|
||||
index == state.groups_column_index -> :groups
|
||||
index == state.fee_type_column_index -> :fee_type
|
||||
MapSet.member?(ignored_headers, header) -> :ignored
|
||||
MapSet.member?(member_indices, index) -> :member_field
|
||||
MapSet.member?(custom_indices, index) -> :custom_field
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
defp role_label(:member_field), do: gettext("Member field")
|
||||
defp role_label(:custom_field), do: gettext("Custom field")
|
||||
defp role_label(:groups), do: gettext("Groups")
|
||||
defp role_label(:fee_type), do: gettext("Fee type")
|
||||
defp role_label(:ignored), do: gettext("Ignored (system-computed field)")
|
||||
defp role_label(:unknown), do: gettext("Unknown (ignored)")
|
||||
|
||||
defp role_badge_class(:member_field), do: "badge-primary"
|
||||
defp role_badge_class(:custom_field), do: "badge-secondary"
|
||||
defp role_badge_class(:groups), do: "badge-success"
|
||||
defp role_badge_class(:fee_type), do: "badge-warning"
|
||||
defp role_badge_class(:ignored), do: "badge-ghost"
|
||||
defp role_badge_class(:unknown), do: "badge-error"
|
||||
|
||||
defp role_row_class(:ignored), do: "opacity-50"
|
||||
defp role_row_class(:unknown), do: "opacity-50"
|
||||
defp role_row_class(_), do: nil
|
||||
|
||||
defp column_samples([], col_count), do: List.duplicate([], col_count)
|
||||
|
||||
defp column_samples(rows, col_count) do
|
||||
Enum.map(0..(col_count - 1), fn col_idx ->
|
||||
rows
|
||||
|> Enum.map(fn row -> Enum.at(row, col_idx, "") end)
|
||||
|> pad_to(3, "")
|
||||
end)
|
||||
end
|
||||
|
||||
defp pad_to(list, target, fill) do
|
||||
list ++ List.duplicate(fill, max(0, target - length(list)))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders import progress text and, when done or aborted, the import results section.
|
||||
"""
|
||||
|
|
@ -254,8 +449,10 @@ defmodule MvWeb.ImportLive.Components do
|
|||
@doc """
|
||||
Returns whether the Start Import button should be disabled.
|
||||
"""
|
||||
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
|
||||
@spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) ::
|
||||
boolean()
|
||||
def import_button_disabled?(:running, _entries), do: true
|
||||
def import_button_disabled?(:preview, _entries), do: true
|
||||
def import_button_disabled?(_status, []), do: true
|
||||
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
|
||||
def import_button_disabled?(_status, _entries), do: false
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ defmodule MvWeb.Router do
|
|||
# Import (Admin only)
|
||||
live "/admin/import", ImportLive
|
||||
|
||||
# Dynamic CSV import templates (admin only; generated from current custom fields)
|
||||
get "/admin/import/template/en", ImportTemplateController, :en
|
||||
get "/admin/import/template/de", ImportTemplateController, :de
|
||||
|
||||
post "/members/export.csv", MemberExportController, :export
|
||||
post "/members/export.pdf", MemberPdfExportController, :export
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
|
|
|
|||
42
mix.lock
42
mix.lock
|
|
@ -1,5 +1,5 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
|
||||
"ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
|
||||
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
|
|
@ -20,32 +20,31 @@
|
|||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
|
||||
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
|
||||
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
|
||||
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
|
||||
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
|
||||
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
|
||||
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
|
||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||
"hammer": {:hex, :hammer, "7.4.0", "7ec06643280583b73245d360c6c8797c080ad6cc45788206abc4358eadd70414", [:mix], [], "hexpm", "ae50e0cadd17c68e2379eb8bf06b63bc882a2f9bd6350f8a2c2727c56d082b3d"},
|
||||
"hammer": {:hex, :hammer, "7.3.0", "2e9759b2aea75d070eacd7f7239d5ea74991762788a22223d3cc1d555c7475a0", [:mix], [], "hexpm", "cfd88c4075c03be92b2fbbcb35af354315e21c6dd47adf7947a2d500cbebef2c"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
|
||||
"igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [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", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"},
|
||||
"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.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [: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", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
|
||||
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [: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", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
|
||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
|
|
@ -56,20 +55,19 @@
|
|||
"live_debugger": {:hex, :live_debugger, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"},
|
||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
|
||||
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
|
||||
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
|
|
@ -80,31 +78,31 @@
|
|||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
|
||||
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
|
||||
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [: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]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, 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", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
|
||||
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [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", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
|
||||
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [: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", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
|
||||
"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.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [: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", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
|
||||
"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"},
|
||||
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
|
||||
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
|
||||
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
|
||||
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.26.0", "8fb146d261e3c4df2d828df0e28a5f233674976c9464c5177f13b7a431a266a9", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "83fe2c7c6572d21d5cdb47766ea041c13da234d09c96220c480eb90b21b1335c"},
|
||||
"swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
|
||||
"tz": {:hex, :tz, "0.28.2", "6c47f3d1a8ee5c33a1d8f0ba49e5a851b0a30c408a587d907aff6e71228b3b32", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "a6bf7355a33f0a7511602ab4566432ac0901d8abff81e94e455bca19708a0c87"},
|
||||
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
|
||||
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur z
|
|||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -1329,6 +1330,7 @@ msgstr "Feb."
|
|||
msgid "Fee Type"
|
||||
msgstr "Beitragsart"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type"
|
||||
|
|
@ -1488,6 +1490,7 @@ msgstr "Gruppe erfolgreich gespeichert."
|
|||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
|
|
@ -2643,6 +2646,7 @@ msgstr "Geprüft von"
|
|||
msgid "Reviewed at"
|
||||
msgstr "Geprüft am"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -3303,11 +3307,6 @@ msgstr "Aufhebung der Verknüpfung geplant"
|
|||
msgid "Unpaid"
|
||||
msgstr "Unbezahlt"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -3968,7 +3967,112 @@ msgstr "Zeitraum"
|
|||
msgid "To"
|
||||
msgstr "Bis"
|
||||
|
||||
#~ #: lib/mv_web/live/group_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "No members selected."
|
||||
#~ msgstr "Keine Mitglieder ausgewählt."
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwendet."
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group assignment failed: %{reason}"
|
||||
msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm and Import"
|
||||
msgstr "Bestätigen und importieren"
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No prepared import to confirm. Please upload again."
|
||||
msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview import"
|
||||
msgstr "Importvorschau"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Column"
|
||||
msgstr "Spalte"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field"
|
||||
msgstr "Benutzerdefiniertes Feld"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Ignored (system-computed field)"
|
||||
msgstr "Ignoriert (vom System berechnetes Feld)"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field"
|
||||
msgstr "Mitgliedsfeld"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rows with an empty fee type will get the default fee type."
|
||||
msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "These groups will be created automatically: %{names}"
|
||||
msgstr "Diese Gruppen werden automatisch erstellt: %{names}"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown (ignored)"
|
||||
msgstr "Unbekannt (ignoriert)"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown fee types (members get the default): %{names}"
|
||||
msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create custom field"
|
||||
msgstr "Datenfeld erstellen"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create fee type"
|
||||
msgstr "Beitragsart erstellen"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 1"
|
||||
msgstr "Zeile 1"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 2"
|
||||
msgstr "Zeile 2"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr "Zeile 3"
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -1330,6 +1331,7 @@ msgstr ""
|
|||
msgid "Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type"
|
||||
|
|
@ -1489,6 +1491,7 @@ msgstr ""
|
|||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
|
|
@ -2644,6 +2647,7 @@ msgstr ""
|
|||
msgid "Reviewed at"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -3304,11 +3308,6 @@ msgstr ""
|
|||
msgid "Unpaid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -3967,3 +3966,113 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "To"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group assignment failed: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm and Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No prepared import to confirm. Please upload again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Column"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Ignored (system-computed field)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rows with an empty fee type will get the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "These groups will be created automatically: %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown (ignored)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown fee types (members get the default): %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 1"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 2"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -1330,6 +1331,7 @@ msgstr ""
|
|||
msgid "Fee Type"
|
||||
msgstr "Fee Type"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Fee type"
|
||||
|
|
@ -1489,6 +1491,7 @@ msgstr ""
|
|||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
|
|
@ -2644,6 +2647,7 @@ msgstr "Review by"
|
|||
msgid "Reviewed at"
|
||||
msgstr "Review date"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
|
|
@ -3304,11 +3308,6 @@ msgstr ""
|
|||
msgid "Unpaid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
|
|
@ -3968,7 +3967,112 @@ msgstr ""
|
|||
msgid "To"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/group_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "No members selected."
|
||||
#~ msgstr ""
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Group assignment failed: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm and Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No prepared import to confirm. Please upload again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Column"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Ignored (system-computed field)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rows with an empty fee type will get the default fee type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "These groups will be created automatically: %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Unknown (ignored)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unknown fee types (members get the default): %{names}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 1"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 2"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr ""
|
||||
|
|
|
|||
27
test/mv/membership/custom_field_value_formatter_test.exs
Normal file
27
test/mv/membership/custom_field_value_formatter_test.exs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Mv.Membership.CustomFieldValueFormatterTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Membership.CustomFieldValueFormatter
|
||||
|
||||
describe "format_custom_field_value/2 for :date" do
|
||||
test "formats an Ash.Union date value as ISO-8601" do
|
||||
union = %Ash.Union{value: ~D[2024-03-15], type: :date}
|
||||
|
||||
assert CustomFieldValueFormatter.format_custom_field_value(union, %{value_type: :date}) ==
|
||||
"2024-03-15"
|
||||
end
|
||||
|
||||
test "formats a direct Date value as ISO-8601" do
|
||||
assert CustomFieldValueFormatter.format_custom_field_value(~D[2024-03-15], %{
|
||||
value_type: :date
|
||||
}) == "2024-03-15"
|
||||
end
|
||||
|
||||
test "formats an already-stored ISO-8601 string date as ISO-8601" do
|
||||
union = %Ash.Union{value: "2024-03-15", type: :date}
|
||||
|
||||
assert CustomFieldValueFormatter.format_custom_field_value(union, %{value_type: :date}) ==
|
||||
"2024-03-15"
|
||||
end
|
||||
end
|
||||
end
|
||||
72
test/mv/membership/import/column_resolver_query_test.exs
Normal file
72
test/mv/membership/import/column_resolver_query_test.exs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
defmodule Mv.Membership.Import.ColumnResolverQueryTest do
|
||||
# async: false — attaches a global telemetry handler to inspect emitted SQL.
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership.Import.ColumnResolver
|
||||
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
describe "create_or_find_group/3 group lookup is name-filtered (no full-table scan)" do
|
||||
test "resolving a new name absent from the snapshot queries by name, not the whole table",
|
||||
%{actor: actor} do
|
||||
# Populate the table so a full-table read would be costly and observable.
|
||||
for n <- 1..20, do: Mv.Fixtures.group_fixture(%{name: "Existing #{n}"})
|
||||
|
||||
queries =
|
||||
capture_group_select_queries(fn ->
|
||||
# The name is absent from the (empty) snapshot, forcing a DB lookup
|
||||
# before the create attempt. That lookup must filter by name.
|
||||
assert {:ok, group} = ColumnResolver.create_or_find_group("New One", [], actor)
|
||||
assert group.name == "New One"
|
||||
end)
|
||||
|
||||
# No SELECT against the groups table issued during resolution may be an
|
||||
# unfiltered full-table scan. The pre-create existence check must filter by
|
||||
# name (carry a WHERE predicate).
|
||||
refute Enum.any?(queries, &unfiltered_groups_select?/1),
|
||||
"expected no unfiltered groups table scan, got:\n#{Enum.join(queries, "\n")}"
|
||||
end
|
||||
end
|
||||
|
||||
defp capture_group_select_queries(fun) do
|
||||
test_pid = self()
|
||||
handler_id = "test-group-query-#{System.unique_integer([:positive])}"
|
||||
|
||||
:telemetry.attach(
|
||||
handler_id,
|
||||
[:mv, :repo, :query],
|
||||
fn _event, _measurements, metadata, _config ->
|
||||
sql = metadata[:query] || ""
|
||||
|
||||
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"groups\"") do
|
||||
send(test_pid, {:group_query, sql})
|
||||
end
|
||||
end,
|
||||
nil
|
||||
)
|
||||
|
||||
try do
|
||||
fun.()
|
||||
after
|
||||
:telemetry.detach(handler_id)
|
||||
end
|
||||
|
||||
collect_group_queries([])
|
||||
end
|
||||
|
||||
defp collect_group_queries(acc) do
|
||||
receive do
|
||||
{:group_query, sql} -> collect_group_queries([sql | acc])
|
||||
after
|
||||
0 -> Enum.reverse(acc)
|
||||
end
|
||||
end
|
||||
|
||||
# An unfiltered groups SELECT reads the whole table: it selects FROM "groups"
|
||||
# with no WHERE clause at all. A name-filtered lookup carries a WHERE predicate.
|
||||
defp unfiltered_groups_select?(sql) do
|
||||
String.contains?(sql, "FROM \"groups\"") and not String.contains?(sql, "WHERE")
|
||||
end
|
||||
end
|
||||
227
test/mv/membership/import/column_resolver_test.exs
Normal file
227
test/mv/membership/import/column_resolver_test.exs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
defmodule Mv.Membership.Import.ColumnResolverTest do
|
||||
use Mv.DataCase, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Mv.Membership.Import.ColumnResolver
|
||||
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
defp fee_type_fixture(name, actor) do
|
||||
{:ok, fee_type} =
|
||||
Mv.MembershipFees.create_membership_fee_type(
|
||||
%{name: name, amount: Decimal.new("10.00"), interval: :yearly},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
fee_type
|
||||
end
|
||||
|
||||
defp header_maps(overrides) do
|
||||
Map.merge(
|
||||
%{
|
||||
member: %{email: 0},
|
||||
custom: %{},
|
||||
unknown: [],
|
||||
ignored: [],
|
||||
groups_column_index: nil,
|
||||
fee_type_column_index: nil
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
describe "resolve/3 group classification" do
|
||||
test "splits group names into found (existing) and to_create (missing)", %{actor: actor} do
|
||||
existing = Mv.Fixtures.group_fixture(%{name: "Orchester"})
|
||||
|
||||
maps = header_maps(%{member: %{email: 0}, groups_column_index: 1})
|
||||
|
||||
rows = [
|
||||
{2, ["a@example.com", "Orchester"]},
|
||||
{3, ["b@example.com", "Neues Ensemble"]}
|
||||
]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert Enum.any?(result.groups_found, &(&1.name == "Orchester" and &1.id == existing.id))
|
||||
assert "Neues Ensemble" in result.groups_to_create
|
||||
refute "Orchester" in result.groups_to_create
|
||||
end
|
||||
|
||||
test "groups_found and groups_to_create are empty when no groups column", %{actor: actor} do
|
||||
maps = header_maps(%{})
|
||||
rows = [{2, ["a@example.com"]}]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert result.groups_found == []
|
||||
assert result.groups_to_create == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/3 preview rows" do
|
||||
test "returns up to 3 preview rows", %{actor: actor} do
|
||||
maps = header_maps(%{})
|
||||
|
||||
rows = [
|
||||
{2, ["a@example.com"]},
|
||||
{3, ["b@example.com"]},
|
||||
{4, ["c@example.com"]},
|
||||
{5, ["d@example.com"]}
|
||||
]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert length(result.preview_rows) == 3
|
||||
assert result.preview_rows == [["a@example.com"], ["b@example.com"], ["c@example.com"]]
|
||||
end
|
||||
|
||||
test "returns fewer preview rows when file has fewer data rows", %{actor: actor} do
|
||||
maps = header_maps(%{})
|
||||
rows = [{2, ["a@example.com"]}]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert result.preview_rows == [["a@example.com"]]
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/3 fee-type resolution" do
|
||||
test "maps known fee-type names to their id by normalized name", %{actor: actor} do
|
||||
standard = fee_type_fixture("Standard", actor)
|
||||
|
||||
maps = header_maps(%{fee_type_column_index: 1})
|
||||
rows = [{2, ["a@example.com", "Standard"]}]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert result.fee_type_map["standard"] == standard.id
|
||||
assert result.fee_type_warnings == []
|
||||
end
|
||||
|
||||
test "records a warning for an unknown fee-type name", %{actor: actor} do
|
||||
maps = header_maps(%{fee_type_column_index: 1})
|
||||
rows = [{2, ["a@example.com", "Nonexistent Type"]}]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert "Nonexistent Type" in result.fee_type_warnings
|
||||
end
|
||||
|
||||
test "sets has_empty_fee_type_cells? when a fee-type cell is blank", %{actor: actor} do
|
||||
fee_type_fixture("Standard", actor)
|
||||
|
||||
maps = header_maps(%{fee_type_column_index: 1})
|
||||
|
||||
rows = [
|
||||
{2, ["a@example.com", "Standard"]},
|
||||
{3, ["b@example.com", " "]}
|
||||
]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert result.has_empty_fee_type_cells? == true
|
||||
end
|
||||
|
||||
test "has_empty_fee_type_cells? is false when all cells filled", %{actor: actor} do
|
||||
fee_type_fixture("Standard", actor)
|
||||
|
||||
maps = header_maps(%{fee_type_column_index: 1})
|
||||
rows = [{2, ["a@example.com", "Standard"]}]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert result.has_empty_fee_type_cells? == false
|
||||
end
|
||||
|
||||
test "fee-type resolution defaults are empty when no fee-type column", %{actor: actor} do
|
||||
maps = header_maps(%{})
|
||||
rows = [{2, ["a@example.com"]}]
|
||||
|
||||
result = ColumnResolver.resolve(maps, rows, actor)
|
||||
|
||||
assert result.fee_type_map == %{}
|
||||
assert result.fee_type_warnings == []
|
||||
assert result.has_empty_fee_type_cells? == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_or_find_group/3" do
|
||||
test "creates a new group when none exists", %{actor: actor} do
|
||||
assert {:ok, group} = ColumnResolver.create_or_find_group("Brand New Group", [], actor)
|
||||
assert group.name == "Brand New Group"
|
||||
end
|
||||
|
||||
test "returns the existing group from the pre-fetched list without creating", %{actor: actor} do
|
||||
existing = Mv.Fixtures.group_fixture(%{name: "Existing Group"})
|
||||
before_count = length(Mv.Membership.list_groups!(actor: actor))
|
||||
|
||||
assert {:ok, group} =
|
||||
ColumnResolver.create_or_find_group("Existing Group", [existing], actor)
|
||||
|
||||
assert group.id == existing.id
|
||||
assert length(Mv.Membership.list_groups!(actor: actor)) == before_count
|
||||
end
|
||||
|
||||
test "resolves to a group created concurrently after the snapshot was taken",
|
||||
%{actor: actor} do
|
||||
# Simulates a concurrent import session: the group name is absent from the
|
||||
# caller's pre-fetched snapshot, but the group now exists in the DB. The
|
||||
# resolver must link to the existing group, never error or duplicate it.
|
||||
stale_snapshot = []
|
||||
_concurrently_created = Mv.Fixtures.group_fixture(%{name: "Concurrent Group"})
|
||||
before_count = length(Mv.Membership.list_groups!(actor: actor))
|
||||
|
||||
assert {:ok, group} =
|
||||
ColumnResolver.create_or_find_group("Concurrent Group", stale_snapshot, actor)
|
||||
|
||||
assert group.name == "Concurrent Group"
|
||||
assert length(Mv.Membership.list_groups!(actor: actor)) == before_count
|
||||
end
|
||||
|
||||
property "is idempotent: same names never create duplicate groups", %{actor: actor} do
|
||||
check all(
|
||||
names <-
|
||||
StreamData.list_of(
|
||||
StreamData.string(:alphanumeric, min_length: 1, max_length: 20),
|
||||
min_length: 1,
|
||||
max_length: 5
|
||||
),
|
||||
max_runs: 25
|
||||
) do
|
||||
names = Enum.map(names, &("grp-" <> &1))
|
||||
|
||||
existing = Mv.Membership.list_groups!(actor: actor)
|
||||
first_ids = resolve_all(names, existing, actor)
|
||||
|
||||
existing_after = Mv.Membership.list_groups!(actor: actor)
|
||||
second_ids = resolve_all(names, existing_after, actor)
|
||||
|
||||
# Same name always resolves to the same group id across both passes.
|
||||
assert first_ids == second_ids
|
||||
|
||||
# No duplicate groups exist for any of the names (case-insensitive).
|
||||
all_groups = Mv.Membership.list_groups!(actor: actor)
|
||||
|
||||
for name <- Enum.uniq_by(names, &String.downcase/1) do
|
||||
matching =
|
||||
Enum.filter(all_groups, fn g ->
|
||||
String.downcase(g.name) == String.downcase(name)
|
||||
end)
|
||||
|
||||
assert length(matching) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_all(names, existing, actor) do
|
||||
Enum.map(names, fn name ->
|
||||
{:ok, group} = ColumnResolver.create_or_find_group(name, existing, actor)
|
||||
{String.downcase(name), group.id}
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Mv.Membership.Import.HeaderMapperTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
|
|
@ -272,4 +273,167 @@ defmodule Mv.Membership.Import.HeaderMapperTest do
|
|||
assert unknown == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_maps/2 fee-status ignore list" do
|
||||
test "places fee-status variants in ignored, not member or custom map" do
|
||||
headers = ["email", "Bezahlstatus"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.member[:email] == 0
|
||||
assert result.custom == %{}
|
||||
assert result.ignored == [1]
|
||||
refute Map.has_key?(result.member, :bezahlstatus)
|
||||
end
|
||||
|
||||
test "ignores membership_fee_status snake-case variant" do
|
||||
headers = ["email", "membership_fee_status"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.ignored == [1]
|
||||
assert result.custom == %{}
|
||||
end
|
||||
|
||||
test "ignores German Mitgliedsbeitragsstatus variant" do
|
||||
headers = ["email", "Mitgliedsbeitragsstatus"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.ignored == [1]
|
||||
end
|
||||
|
||||
test "fee-status takes priority over a same-named custom field" do
|
||||
headers = ["email", "Bezahlstatus"]
|
||||
custom_fields = [%{id: "cf1", name: "Bezahlstatus"}]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert result.ignored == [1]
|
||||
assert result.custom == %{}
|
||||
end
|
||||
|
||||
test "result carries groups_column_index and fee_type_column_index keys" do
|
||||
assert {:ok, result} = HeaderMapper.build_maps(["email"], [])
|
||||
|
||||
assert Map.has_key?(result, :groups_column_index)
|
||||
assert Map.has_key?(result, :fee_type_column_index)
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_maps/2 groups column detection" do
|
||||
test "detects German Gruppen variant and excludes it from member/custom maps" do
|
||||
headers = ["email", "Gruppen"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.groups_column_index == 1
|
||||
assert result.custom == %{}
|
||||
assert result.unknown == []
|
||||
refute Map.has_key?(result.member, :gruppen)
|
||||
end
|
||||
|
||||
test "detects English Groups variant" do
|
||||
headers = ["email", "Groups"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.groups_column_index == 1
|
||||
end
|
||||
|
||||
test "detects singular Gruppe and lowercase groups variants" do
|
||||
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "Gruppe"], [])
|
||||
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "groups"], [])
|
||||
end
|
||||
|
||||
test "groups column takes priority over a same-named custom field" do
|
||||
headers = ["email", "Gruppen"]
|
||||
custom_fields = [%{id: "cf1", name: "Gruppen"}]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert result.groups_column_index == 1
|
||||
assert result.custom == %{}
|
||||
end
|
||||
|
||||
test "groups_column_index is nil when no groups column present" do
|
||||
assert {:ok, %{groups_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_maps/2 fee-type column detection" do
|
||||
test "detects German Beitragsart variant and excludes it from member/custom maps" do
|
||||
headers = ["email", "Beitragsart"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.fee_type_column_index == 1
|
||||
assert result.custom == %{}
|
||||
assert result.unknown == []
|
||||
end
|
||||
|
||||
test "detects English fee type variants" do
|
||||
assert {:ok, %{fee_type_column_index: 1}} =
|
||||
HeaderMapper.build_maps(["email", "Fee Type"], [])
|
||||
|
||||
assert {:ok, %{fee_type_column_index: 1}} =
|
||||
HeaderMapper.build_maps(["email", "fee type"], [])
|
||||
|
||||
assert {:ok, %{fee_type_column_index: 1}} =
|
||||
HeaderMapper.build_maps(["email", "fee_type"], [])
|
||||
|
||||
assert {:ok, %{fee_type_column_index: 1}} =
|
||||
HeaderMapper.build_maps(["email", "membership_fee_type"], [])
|
||||
end
|
||||
|
||||
test "fee-type column takes priority over a same-named custom field" do
|
||||
headers = ["email", "Beitragsart"]
|
||||
custom_fields = [%{id: "cf1", name: "Beitragsart"}]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
|
||||
|
||||
assert result.fee_type_column_index == 1
|
||||
assert result.custom == %{}
|
||||
end
|
||||
|
||||
test "fee_type_column_index is nil when no fee-type column present" do
|
||||
assert {:ok, %{fee_type_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
|
||||
end
|
||||
|
||||
test "detects groups and fee-type columns together" do
|
||||
headers = ["email", "Gruppen", "Beitragsart"]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||||
|
||||
assert result.groups_column_index == 1
|
||||
assert result.fee_type_column_index == 2
|
||||
assert result.member[:email] == 0
|
||||
assert result.custom == %{}
|
||||
assert result.unknown == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_maps/2 fee-status ignore property" do
|
||||
property "every fee-status variant is ignored, never member or custom" do
|
||||
check all(
|
||||
variant <-
|
||||
StreamData.member_of([
|
||||
"Membership Fee Status",
|
||||
"membership_fee_status",
|
||||
"Mitgliedsbeitragsstatus",
|
||||
"Bezahlstatus",
|
||||
" Bezahlstatus ",
|
||||
"BEZAHLSTATUS"
|
||||
])
|
||||
) do
|
||||
custom_fields = [%{id: "cf1", name: variant}]
|
||||
|
||||
assert {:ok, result} = HeaderMapper.build_maps(["email", variant], custom_fields)
|
||||
|
||||
assert result.ignored == [1]
|
||||
assert result.custom == %{}
|
||||
refute Map.has_key?(result.member, :bezahlstatus)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,83 @@ defmodule Mv.Membership.Import.ImportRunnerTest do
|
|||
|
||||
alias Mv.Membership.Import.ImportRunner
|
||||
|
||||
describe "carry_groups_forward/2" do
|
||||
test "replaces import_state groups_found with the chunk's grown snapshot" do
|
||||
import_state = %{groups_found: [%{id: "1", name: "A"}]}
|
||||
chunk_result = %{groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}]}
|
||||
|
||||
assert ImportRunner.carry_groups_forward(import_state, chunk_result) == %{
|
||||
groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}]
|
||||
}
|
||||
end
|
||||
|
||||
test "leaves import_state unchanged when the chunk result omits groups_found" do
|
||||
import_state = %{groups_found: [%{id: "1", name: "A"}], other: :kept}
|
||||
chunk_result = %{inserted: 1}
|
||||
|
||||
assert ImportRunner.carry_groups_forward(import_state, chunk_result) == import_state
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_progress/4 warning accumulation" do
|
||||
test "deduplicates identical warnings across chunks instead of growing unbounded" do
|
||||
progress = %{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: ["Fee type 'Ghost' not found; using the default fee type."],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: 3
|
||||
}
|
||||
|
||||
chunk_result = %{
|
||||
inserted: 2,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
errors_truncated?: false,
|
||||
warnings: [
|
||||
"Fee type 'Ghost' not found; using the default fee type.",
|
||||
"Fee type 'Ghost' not found; using the default fee type."
|
||||
]
|
||||
}
|
||||
|
||||
result = ImportRunner.merge_progress(progress, chunk_result, 0)
|
||||
|
||||
assert result.warnings == ["Fee type 'Ghost' not found; using the default fee type."]
|
||||
end
|
||||
|
||||
test "preserves distinct warnings while collapsing duplicates" do
|
||||
progress = %{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: ["Fee type 'A' not found; using the default fee type."],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: 2
|
||||
}
|
||||
|
||||
chunk_result = %{
|
||||
inserted: 1,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
errors_truncated?: false,
|
||||
warnings: [
|
||||
"Fee type 'A' not found; using the default fee type.",
|
||||
"Fee type 'B' not found; using the default fee type."
|
||||
]
|
||||
}
|
||||
|
||||
result = ImportRunner.merge_progress(progress, chunk_result, 0)
|
||||
|
||||
assert result.warnings == [
|
||||
"Fee type 'A' not found; using the default fee type.",
|
||||
"Fee type 'B' not found; using the default fee type."
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_file_entry/2" do
|
||||
test "returns {:ok, content} for a readable file" do
|
||||
path =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Mv.Membership.Import.MemberCSVTest do
|
||||
use Mv.DataCase, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
|
||||
|
|
@ -899,4 +900,302 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
assert import_state.chunks != []
|
||||
end
|
||||
end
|
||||
|
||||
describe "prepare/2 column resolution integration" do
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
test "exposes resolver output keys in import_state", %{actor: actor} do
|
||||
csv_content = "email\njohn@example.com"
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
||||
|
||||
assert Map.has_key?(import_state, :ignored)
|
||||
assert Map.has_key?(import_state, :groups_to_create)
|
||||
assert Map.has_key?(import_state, :fee_type_map)
|
||||
assert Map.has_key?(import_state, :fee_type_warnings)
|
||||
assert Map.has_key?(import_state, :has_empty_fee_type_cells?)
|
||||
assert Map.has_key?(import_state, :preview_rows)
|
||||
end
|
||||
|
||||
test "fee-status column is reported as ignored, not as a custom field", %{actor: actor} do
|
||||
{:ok, _custom_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Bezahlstatus", value_type: :string})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
csv_content = "email;Bezahlstatus\njohn@example.com;paid"
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
||||
|
||||
assert import_state.ignored == ["Bezahlstatus"]
|
||||
assert import_state.custom_field_map == %{}
|
||||
end
|
||||
|
||||
test "preview rows are limited to 3", %{actor: actor} do
|
||||
csv_content = "email\na@example.com\nb@example.com\nc@example.com\nd@example.com"
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
||||
|
||||
assert length(import_state.preview_rows) == 3
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_chunk/4 fee-type assignment" do
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, fee_type} =
|
||||
Mv.MembershipFees.create_membership_fee_type(
|
||||
%{name: "Premium", amount: Decimal.new("25.00"), interval: :yearly},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
%{actor: actor, fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "sets membership_fee_type_id when fee-type cell matches a known type", %{
|
||||
actor: actor,
|
||||
fee_type: fee_type
|
||||
} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "fee-known@example.com"}, custom: %{}, fee_type: "Premium"}}
|
||||
]
|
||||
|
||||
opts = [
|
||||
actor: actor,
|
||||
fee_type_map: %{"premium" => fee_type.id}
|
||||
]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
member =
|
||||
Mv.Membership.list_members!(actor: actor)
|
||||
|> Enum.find(&(&1.email == "fee-known@example.com"))
|
||||
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "adds a warning when the fee-type name is unknown", %{actor: actor} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "fee-unknown@example.com"}, custom: %{}, fee_type: "Ghost Type"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, fee_type_map: %{}]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
assert Enum.any?(result.warnings, &(&1 =~ "Ghost Type"))
|
||||
end
|
||||
|
||||
test "uses the default fee type when the fee-type cell is empty", %{
|
||||
actor: actor,
|
||||
fee_type: fee_type
|
||||
} do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
{:ok, _settings} =
|
||||
Mv.Membership.update_settings(
|
||||
settings,
|
||||
%{default_membership_fee_type_id: fee_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
chunk = [{2, %{member: %{email: "fee-empty@example.com"}, custom: %{}, fee_type: ""}}]
|
||||
|
||||
opts = [actor: actor, fee_type_map: %{}]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
member =
|
||||
Mv.Membership.list_members!(actor: actor)
|
||||
|> Enum.find(&(&1.email == "fee-empty@example.com"))
|
||||
|
||||
# Default fee type assigned via SetDefaultMembershipFeeType.
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_chunk/4 group assignment" do
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
defp group_names_for(email, actor) do
|
||||
member =
|
||||
Mv.Membership.list_members!(actor: actor)
|
||||
|> Enum.find(&(&1.email == email))
|
||||
|
||||
member = Ash.load!(member, :groups, actor: actor)
|
||||
member.groups |> Enum.map(& &1.name) |> Enum.sort()
|
||||
end
|
||||
|
||||
test "assigns member to an existing group", %{actor: actor} do
|
||||
existing = Mv.Fixtures.group_fixture(%{name: "Orchester"})
|
||||
|
||||
chunk = [
|
||||
{2, %{member: %{email: "g-existing@example.com"}, custom: %{}, groups: "Orchester"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, groups_found: [existing]]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
assert group_names_for("g-existing@example.com", actor) == ["Orchester"]
|
||||
|
||||
# No new group was created.
|
||||
orchester = Enum.filter(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Orchester"))
|
||||
assert length(orchester) == 1
|
||||
end
|
||||
|
||||
test "auto-creates an unknown group and assigns the member", %{actor: actor} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "g-new@example.com"}, custom: %{}, groups: "Frische Gruppe"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
assert group_names_for("g-new@example.com", actor) == ["Frische Gruppe"]
|
||||
assert Enum.any?(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Frische Gruppe"))
|
||||
end
|
||||
|
||||
test "handles multiple comma-separated groups", %{actor: actor} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "g-multi@example.com"}, custom: %{}, groups: "Orchester, Chor"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
assert group_names_for("g-multi@example.com", actor) == ["Chor", "Orchester"]
|
||||
end
|
||||
|
||||
test "does not re-read the group table once per row for a repeated novel name",
|
||||
%{actor: actor} do
|
||||
rows =
|
||||
for i <- 1..10 do
|
||||
{i + 1,
|
||||
%{member: %{email: "g-nplus1-#{i}@example.com"}, custom: %{}, groups: "Shared Group"}}
|
||||
end
|
||||
|
||||
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
|
||||
test_pid = self()
|
||||
|
||||
# process_chunk runs synchronously in this test process, so the telemetry
|
||||
# handler (invoked in the query-executing process) sees self() == test_pid.
|
||||
# Filtering on the pid keeps concurrent tests' group queries out of the count.
|
||||
handler = fn _event, _measurements, metadata, _config ->
|
||||
if self() == test_pid and metadata[:source] == "groups" and
|
||||
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
|
||||
Agent.update(group_read_count, &(&1 + 1))
|
||||
end
|
||||
end
|
||||
|
||||
handler_id = "test-group-read-counter-#{System.unique_integer([:positive])}"
|
||||
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
|
||||
|
||||
assert {:ok, %{inserted: 10}} =
|
||||
MemberCSV.process_chunk(rows, %{email: 0}, %{}, actor: actor, groups_found: [])
|
||||
|
||||
reads = Agent.get(group_read_count, & &1)
|
||||
:telemetry.detach(handler_id)
|
||||
|
||||
# The novel group is created on the first row and reused in memory for the
|
||||
# remaining nine. Without accumulation each row triggers a fresh full-table
|
||||
# read, scaling linearly with the row count.
|
||||
assert reads <= 3,
|
||||
"Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)."
|
||||
end
|
||||
|
||||
test "returns the grown group snapshot so later chunks skip the table read",
|
||||
%{actor: actor} do
|
||||
chunk1 = [
|
||||
{2, %{member: %{email: "g-xchunk-1@example.com"}, custom: %{}, groups: "Shared X"}}
|
||||
]
|
||||
|
||||
chunk2 = [
|
||||
{3, %{member: %{email: "g-xchunk-2@example.com"}, custom: %{}, groups: "Shared X"}}
|
||||
]
|
||||
|
||||
assert {:ok, result1} =
|
||||
MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, actor: actor, groups_found: [])
|
||||
|
||||
# The chunk result must expose the accumulated snapshot, including the group
|
||||
# auto-created while processing this chunk, so the LiveView can thread it
|
||||
# into the next chunk's opts.
|
||||
assert is_list(result1.groups_found)
|
||||
assert Enum.any?(result1.groups_found, &(&1.name == "Shared X"))
|
||||
|
||||
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
|
||||
test_pid = self()
|
||||
|
||||
handler = fn _event, _measurements, metadata, _config ->
|
||||
if self() == test_pid and metadata[:source] == "groups" and
|
||||
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
|
||||
Agent.update(group_read_count, &(&1 + 1))
|
||||
end
|
||||
end
|
||||
|
||||
handler_id = "test-xchunk-group-read-#{System.unique_integer([:positive])}"
|
||||
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
|
||||
|
||||
assert {:ok, %{inserted: 1}} =
|
||||
MemberCSV.process_chunk(chunk2, %{email: 0}, %{},
|
||||
actor: actor,
|
||||
groups_found: result1.groups_found
|
||||
)
|
||||
|
||||
reads = Agent.get(group_read_count, & &1)
|
||||
:telemetry.detach(handler_id)
|
||||
|
||||
# The second chunk receives the snapshot grown by the first, so the shared
|
||||
# group resolves from memory without any full-table read.
|
||||
assert reads == 0,
|
||||
"Expected no group table read in the second chunk, got #{reads} (snapshot not threaded across chunks)."
|
||||
end
|
||||
|
||||
test "empty groups cell leaves the member without group assignment", %{actor: actor} do
|
||||
chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}]
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
assert result.errors == []
|
||||
|
||||
assert group_names_for("g-empty@example.com", actor) == []
|
||||
end
|
||||
|
||||
property "re-importing the same groups does not create duplicates", %{actor: actor} do
|
||||
check all(
|
||||
name <- StreamData.string(:alphanumeric, min_length: 1, max_length: 15),
|
||||
max_runs: 15
|
||||
) do
|
||||
group_name = "dup-" <> name
|
||||
email1 = "dup-#{System.unique_integer([:positive])}@example.com"
|
||||
email2 = "dup-#{System.unique_integer([:positive])}@example.com"
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
chunk1 = [{2, %{member: %{email: email1}, custom: %{}, groups: group_name}}]
|
||||
chunk2 = [{2, %{member: %{email: email2}, custom: %{}, groups: group_name}}]
|
||||
|
||||
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, opts)
|
||||
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, opts)
|
||||
|
||||
matching =
|
||||
Mv.Membership.list_groups!(actor: actor)
|
||||
|> Enum.filter(&(String.downcase(&1.name) == String.downcase(group_name)))
|
||||
|
||||
assert length(matching) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
104
test/mv_web/controllers/import_template_controller_test.exs
Normal file
104
test/mv_web/controllers/import_template_controller_test.exs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
defmodule MvWeb.ImportTemplateControllerTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, custom_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Lieblingsfarbe", value_type: :string})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
%{conn: conn, custom_field: custom_field}
|
||||
end
|
||||
|
||||
describe "authenticated EN template" do
|
||||
setup %{conn: conn} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
|
||||
end
|
||||
|
||||
test "returns CSV with English headers and current custom fields", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
|
||||
assert response_content_type(conn, :csv) =~ "text/csv"
|
||||
body = response(conn, 200)
|
||||
|
||||
header = body |> String.split("\n") |> List.first()
|
||||
assert header =~ "email"
|
||||
# EN headers use the canonical English variant from HeaderMapper, not the
|
||||
# underscore form, so the template stays faithful to the documented variant list.
|
||||
assert header =~ "first name"
|
||||
assert header =~ "last name"
|
||||
refute header =~ "first_name"
|
||||
assert header =~ "house number"
|
||||
refute header =~ "house_number"
|
||||
assert header =~ "Lieblingsfarbe"
|
||||
|
||||
assert get_resp_header(conn, "content-disposition")
|
||||
|> Enum.any?(&(&1 =~ "member_import_en.csv"))
|
||||
end
|
||||
|
||||
test "neutralizes formula-injection in a custom field header", %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, _} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "=cmd|'/c calc'!A1",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
body = response(conn, 200)
|
||||
header = body |> String.split("\n") |> List.first()
|
||||
|
||||
# The dangerous cell must be prefixed with a single quote so spreadsheet
|
||||
# software does not evaluate it as a formula, matching the export writer.
|
||||
refute header =~ ~r/(^|;)=cmd/
|
||||
assert header =~ "'=cmd|'/c calc'!A1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "authenticated DE template" do
|
||||
setup %{conn: conn} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
|
||||
end
|
||||
|
||||
test "returns CSV with German headers and current custom fields", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/de")
|
||||
|
||||
body = response(conn, 200)
|
||||
header = body |> String.split("\n") |> List.first()
|
||||
|
||||
assert header =~ "E-Mail"
|
||||
assert header =~ "Vorname"
|
||||
assert header =~ "Lieblingsfarbe"
|
||||
|
||||
assert get_resp_header(conn, "content-disposition")
|
||||
|> Enum.any?(&(&1 =~ "member_import_de.csv"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "authorization" do
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated request does not receive a CSV", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
|
||||
refute conn.status == 200
|
||||
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
|
||||
refute to_string(conn.resp_body) =~ "email"
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "user without import permission is forbidden", %{conn: conn} do
|
||||
conn = get(conn, ~p"/admin/import/template/en")
|
||||
|
||||
refute conn.status == 200
|
||||
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
|
||||
refute to_string(conn.resp_body) =~ "email"
|
||||
end
|
||||
end
|
||||
end
|
||||
31
test/mv_web/live/import_live/components_test.exs
Normal file
31
test/mv_web/live/import_live/components_test.exs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule MvWeb.ImportLive.ComponentsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.ImportLive.Components
|
||||
|
||||
describe "import_button_disabled?/2" do
|
||||
@done_entry %{done?: true}
|
||||
|
||||
test "disables the Start Import button while the preview is displayed" do
|
||||
# During :preview the upload entry is done, but re-clicking Start Import
|
||||
# would re-run the upload processing and overwrite the current preview.
|
||||
assert Components.import_button_disabled?(:preview, [@done_entry]) == true
|
||||
end
|
||||
|
||||
test "disables the button while an import is running" do
|
||||
assert Components.import_button_disabled?(:running, [@done_entry]) == true
|
||||
end
|
||||
|
||||
test "disables the button when there are no upload entries" do
|
||||
assert Components.import_button_disabled?(:idle, []) == true
|
||||
end
|
||||
|
||||
test "disables the button while an upload entry is not yet done" do
|
||||
assert Components.import_button_disabled?(:idle, [%{done?: false}]) == true
|
||||
end
|
||||
|
||||
test "enables the button at idle with a completed upload" do
|
||||
assert Components.import_button_disabled?(:idle, [@done_entry]) == false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -27,6 +27,16 @@ defmodule MvWeb.ImportLiveTest do
|
|||
|
||||
defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit()
|
||||
|
||||
defp confirm_import(view),
|
||||
do: view |> element("[data-testid='confirm-import-button']") |> render_click()
|
||||
|
||||
# Full flow: upload, enter preview (start), then confirm to begin processing.
|
||||
defp run_full_import(view, csv_content, filename \\ "test_import.csv") do
|
||||
upload_csv_file(view, csv_content, filename)
|
||||
submit_import(view)
|
||||
confirm_import(view)
|
||||
end
|
||||
|
||||
defp wait_for_import_completion, do: Process.sleep(1000)
|
||||
|
||||
# ---------- Business logic: Authorization ----------
|
||||
|
|
@ -56,8 +66,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
|> File.read!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
run_full_import(view, csv_content)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
|
@ -121,8 +130,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
invalid_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
submit_import(view)
|
||||
run_full_import(view, csv_content, "invalid_import.csv")
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
|
@ -141,8 +149,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
invalid_rows =
|
||||
for i <- 1..100, do: "Row#{i};Last#{i};;Country#{i};City#{i};Street#{i};12345\n"
|
||||
|
||||
upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
||||
submit_import(view)
|
||||
run_full_import(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
|
@ -174,8 +181,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
submit_import(view)
|
||||
run_full_import(view, csv_content, "bom_import.csv")
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
|
@ -193,8 +199,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
submit_import(view)
|
||||
run_full_import(view, csv_content, "empty_lines.csv")
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
|
|
@ -208,8 +213,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv_content, "unknown_custom.csv")
|
||||
submit_import(view)
|
||||
run_full_import(view, csv_content, "unknown_custom.csv")
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
|
@ -240,23 +244,41 @@ defmodule MvWeb.ImportLiveTest do
|
|||
assert has_element?(view, "[data-testid='start-import-button']")
|
||||
end
|
||||
|
||||
test "template links and file input are present", %{conn: conn} do
|
||||
test "template links point to the dynamic import template routes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
assert has_element?(view, "a[href='/admin/import/template/en']")
|
||||
assert has_element?(view, "a[href='/admin/import/template/de']")
|
||||
refute has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||
end
|
||||
|
||||
test "file input is present", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
|
||||
assert has_element?(view, "label[for='csv_file']")
|
||||
assert has_element?(view, "#csv_file_help")
|
||||
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
|
||||
end
|
||||
|
||||
test "custom fields notice lists accepted groups and fee-type column names", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import")
|
||||
|
||||
# Groups column variants (both EN and DE)
|
||||
assert html =~ "Groups"
|
||||
assert html =~ "Gruppen"
|
||||
# Fee type column variants (both EN and DE)
|
||||
assert html =~ "Beitragsart"
|
||||
assert html =~ "Fee Type"
|
||||
assert html =~ "fee type"
|
||||
# Fee status is always ignored (named explicitly)
|
||||
assert html =~ "Bezahlstatus"
|
||||
end
|
||||
|
||||
test "after successful import, progress container has aria-live", %{conn: conn} do
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
run_full_import(view, csv_content)
|
||||
wait_for_import_completion()
|
||||
assert has_element?(view, "[data-testid='import-progress-container']")
|
||||
html = render(view)
|
||||
|
|
@ -275,4 +297,187 @@ defmodule MvWeb.ImportLiveTest do
|
|||
html = render(view)
|
||||
assert html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
describe "preview state machine" do
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||
|> put_locale_en()
|
||||
|
||||
valid_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, conn: conn, valid_csv: valid_csv}
|
||||
end
|
||||
|
||||
test "start_import transitions to preview without processing", %{
|
||||
conn: conn,
|
||||
valid_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
|
||||
# Preview is shown; no results panel yet because nothing was processed.
|
||||
assert has_element?(view, "[data-testid='import-preview']")
|
||||
refute has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
# No member was created during preview (read-only step).
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, members} = Membership.list_members(actor: system_actor)
|
||||
|
||||
refute Enum.any?(
|
||||
members,
|
||||
&(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"])
|
||||
)
|
||||
end
|
||||
|
||||
test "confirm_import starts processing and creates members", %{
|
||||
conn: conn,
|
||||
valid_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
run_full_import(view, csv_content)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, members} = Membership.list_members(actor: system_actor)
|
||||
|
||||
imported =
|
||||
Enum.filter(
|
||||
members,
|
||||
&(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"])
|
||||
)
|
||||
|
||||
assert length(imported) == 2
|
||||
end
|
||||
|
||||
test "cancel_import returns to idle and hides the preview", %{
|
||||
conn: conn,
|
||||
valid_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
assert has_element?(view, "[data-testid='import-preview']")
|
||||
|
||||
view |> element("[data-testid='cancel-import-button']") |> render_click()
|
||||
|
||||
refute has_element?(view, "[data-testid='import-preview']")
|
||||
refute has_element?(view, "[data-testid='import-results-panel']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "preview contents" do
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||
|> put_locale_en()
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
test "shows the column mapping table with roles for each column", %{conn: conn} do
|
||||
csv = "email;Gruppen;Beitragsart;Bezahlstatus;UnknownCol\na@e.com;Chor;Premium;paid;x"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='preview-mapping-table']")
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "email"
|
||||
assert html =~ "Gruppen"
|
||||
assert html =~ "Beitragsart"
|
||||
assert html =~ "Bezahlstatus"
|
||||
assert html =~ "UnknownCol"
|
||||
end
|
||||
|
||||
test "lists every CSV column exactly once in the mapping table", %{conn: conn} do
|
||||
headers = ["email", "Gruppen", "Beitragsart", "Bezahlstatus", "UnknownCol"]
|
||||
csv = Enum.join(headers, ";") <> "\na@e.com;Chor;Premium;paid;x"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
# Count the data rows via their stable testid so the assertion is independent
|
||||
# of how Phoenix renders class attributes or tr tags (§1.15).
|
||||
html = render(view)
|
||||
|
||||
row_count =
|
||||
html |> String.split(~s(data-testid="preview-column-row")) |> length() |> Kernel.-(1)
|
||||
|
||||
assert row_count == length(headers)
|
||||
end
|
||||
|
||||
test "shows up to 3 sample data rows", %{conn: conn} do
|
||||
csv = "email\nr1@e.com\nr2@e.com\nr3@e.com\nr4@e.com"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "r1@e.com"
|
||||
assert html =~ "r2@e.com"
|
||||
assert html =~ "r3@e.com"
|
||||
refute html =~ "r4@e.com"
|
||||
end
|
||||
|
||||
test "shows an auto-create notice for unknown group names", %{conn: conn} do
|
||||
csv = "email;Gruppen\na@e.com;Ganz Neue Gruppe"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='preview-groups-notice']")
|
||||
assert render(view) =~ "Ganz Neue Gruppe"
|
||||
end
|
||||
|
||||
test "shows a warning and link for unknown fee-type names", %{conn: conn} do
|
||||
csv = "email;Beitragsart\na@e.com;Phantom Tarif"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='preview-fee-type-warning']")
|
||||
html = render(view)
|
||||
assert html =~ "Phantom Tarif"
|
||||
assert html =~ "/membership_fee_settings"
|
||||
end
|
||||
|
||||
test "shows an info notice when fee-type cells are empty", %{conn: conn} do
|
||||
csv = "email;Beitragsart\na@e.com;\nb@e.com;"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='preview-fee-type-info']")
|
||||
end
|
||||
|
||||
test "shows a warning for unknown custom-field columns", %{conn: conn} do
|
||||
csv = "email;TotallyUnknown\na@e.com;value"
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
upload_csv_file(view, csv)
|
||||
submit_import(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='preview-unknown-warning']")
|
||||
assert render(view) =~ "TotallyUnknown"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue