Merge branch 'main' into feat/447_concistency
This commit is contained in:
commit
c7c082b867
27 changed files with 926 additions and 131 deletions
|
|
@ -22,7 +22,6 @@ defmodule Mv.Membership.Member do
|
|||
## Validations
|
||||
- Required: email (all other fields are optional)
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Postal code format: exactly 5 digits (German format)
|
||||
- Date validations: join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||||
|
|
@ -477,11 +476,6 @@ defmodule Mv.Membership.Member do
|
|||
where: [present([:join_date, :exit_date])],
|
||||
message: "cannot be before join date"
|
||||
|
||||
# Postal code format (only if set)
|
||||
validate match(:postal_code, ~r/^\d{5}$/),
|
||||
where: [present(:postal_code)],
|
||||
message: "must consist of 5 digits"
|
||||
|
||||
# Email validation with EctoCommons.EmailValidator
|
||||
validate fn changeset, _ ->
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
|
|
@ -648,6 +642,10 @@ defmodule Mv.Membership.Member do
|
|||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :country, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :search_vector, AshPostgres.Tsvector,
|
||||
writable?: false,
|
||||
public?: false,
|
||||
|
|
@ -1249,7 +1247,8 @@ defmodule Mv.Membership.Member do
|
|||
contains(postal_code, ^query) or
|
||||
contains(house_number, ^query) or
|
||||
contains(email, ^query) or
|
||||
contains(city, ^query)
|
||||
contains(city, ^query) or
|
||||
contains(country, ^query)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Mv.Constants do
|
|||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:country,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
|
|
|
|||
|
|
@ -17,15 +17,24 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
## Member Field Mapping
|
||||
|
||||
Maps CSV headers to canonical member fields:
|
||||
- `email` (required)
|
||||
- `first_name` (optional)
|
||||
- `last_name` (optional)
|
||||
- `street` (optional)
|
||||
- `postal_code` (optional)
|
||||
- `city` (optional)
|
||||
Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for
|
||||
importable attributes). All DB-backed member attributes can be imported.
|
||||
|
||||
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
|
||||
- `email` (required)
|
||||
- `first_name`, `last_name` (optional)
|
||||
- `join_date`, `exit_date` (optional, ISO-8601 date)
|
||||
- `notes` (optional)
|
||||
- `country`, `city`, `street`, `house_number`, `postal_code` (optional)
|
||||
- `membership_fee_start_date` (optional, ISO-8601 date)
|
||||
|
||||
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
|
||||
|
||||
## 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.
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
|
|
@ -75,11 +84,37 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"nachname",
|
||||
"familienname"
|
||||
],
|
||||
join_date: [
|
||||
"join date",
|
||||
"join_date",
|
||||
"beitrittsdatum",
|
||||
"beitritts-datum"
|
||||
],
|
||||
exit_date: [
|
||||
"exit date",
|
||||
"exit_date",
|
||||
"austrittsdatum",
|
||||
"austritts-datum"
|
||||
],
|
||||
notes: [
|
||||
"notes",
|
||||
"notizen",
|
||||
"bemerkungen"
|
||||
],
|
||||
street: [
|
||||
"street",
|
||||
"address",
|
||||
"strasse"
|
||||
],
|
||||
house_number: [
|
||||
"house number",
|
||||
"house_number",
|
||||
"house no",
|
||||
"hausnummer",
|
||||
"nr",
|
||||
"nr.",
|
||||
"nummer"
|
||||
],
|
||||
postal_code: [
|
||||
"postal code",
|
||||
"postal_code",
|
||||
|
|
@ -93,6 +128,18 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"town",
|
||||
"stadt",
|
||||
"ort"
|
||||
],
|
||||
country: [
|
||||
"country",
|
||||
"land",
|
||||
"staat"
|
||||
],
|
||||
membership_fee_start_date: [
|
||||
"membership fee start date",
|
||||
"membership_fee_start_date",
|
||||
"fee start",
|
||||
"beitragsbeginn",
|
||||
"beitrags-beginn"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -549,9 +549,12 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
line_number,
|
||||
actor
|
||||
) do
|
||||
# Convert empty strings to nil for date fields so Ash accepts them
|
||||
member_attrs = sanitize_date_fields(trimmed_member_attrs)
|
||||
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
trimmed_member_attrs
|
||||
member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|
||||
# Only include custom_field_values if not empty
|
||||
|
|
@ -793,6 +796,23 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end)
|
||||
end
|
||||
|
||||
# Converts empty strings to nil for date fields so Ash can accept them
|
||||
@date_fields [:join_date, :exit_date, :membership_fee_start_date]
|
||||
|
||||
defp sanitize_date_fields(attrs) when is_map(attrs) do
|
||||
Enum.reduce(@date_fields, attrs, fn field, acc ->
|
||||
put_date_field(acc, field, Map.get(acc, field))
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_date_field(acc, field, ""), do: Map.put(acc, field, nil)
|
||||
|
||||
defp put_date_field(acc, field, val) when is_binary(val) do
|
||||
if String.trim(val) == "", do: Map.put(acc, field, nil), else: acc
|
||||
end
|
||||
|
||||
defp put_date_field(acc, _field, _), do: acc
|
||||
|
||||
# Formats Ash errors into MemberCSV.Error structs
|
||||
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number, email) do
|
||||
# Try to find email-related errors first (for better error messages)
|
||||
|
|
|
|||
|
|
@ -92,14 +92,22 @@ defmodule MvWeb.ImportLive do
|
|||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@club_name}>
|
||||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<%!-- CSV Import Section --%>
|
||||
<.form_section title={gettext("Import Members (CSV)")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
</.form_section>
|
||||
<div data-testid="import-page">
|
||||
<.header>
|
||||
{gettext("Import Members")}
|
||||
<:subtitle>
|
||||
{gettext("Import members from CSV files.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<.form_section title={gettext("Choose CSV file")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
</.form_section>
|
||||
</div>
|
||||
<% else %>
|
||||
<div role="alert" class="alert alert-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ defmodule MvWeb.ImportLive.Components do
|
|||
"""
|
||||
def custom_fields_notice(assigns) do
|
||||
~H"""
|
||||
<div role="note" class="alert alert-info mb-4">
|
||||
<div role="note" class="alert alert-info mb-4 w-xl">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<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, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
"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."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
|
|
@ -48,7 +48,7 @@ defmodule MvWeb.ImportLive.Components do
|
|||
def template_links(assigns) do
|
||||
~H"""
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-base-content/70 mb-2">
|
||||
<p class="mb-2">
|
||||
{gettext("Download CSV templates:")}
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
|
|
@ -88,22 +88,20 @@ defmodule MvWeb.ImportLive.Components do
|
|||
phx-submit="start_import"
|
||||
data-testid="csv-upload-form"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label for="csv_file" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("CSV File")}
|
||||
</span>
|
||||
<fieldset class="mb-2 fieldset w-md">
|
||||
<label for="csv_file">
|
||||
<span class="mb-1 label">{gettext("CSV File")}</span>
|
||||
</label>
|
||||
<.live_file_input
|
||||
upload={@uploads.csv_file}
|
||||
id="csv_file"
|
||||
class="file-input file-input-bordered w-full"
|
||||
class="file-input file-input-bordered"
|
||||
aria-describedby="csv_file_help"
|
||||
/>
|
||||
<p class="label-text-alt mt-1" id="csv_file_help">
|
||||
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<.button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -90,21 +90,10 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Address Row --%>
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<.input
|
||||
field={@form[:street]}
|
||||
label={gettext("Street")}
|
||||
required={@member_field_required_map[:street]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-16">
|
||||
<.input
|
||||
field={@form[:house_number]}
|
||||
label={gettext("Nr.")}
|
||||
required={@member_field_required_map[:house_number]}
|
||||
/>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input
|
||||
|
|
@ -113,17 +102,23 @@ defmodule MvWeb.MemberLive.Form do
|
|||
required={@member_field_required_map[:postal_code]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<.input
|
||||
field={@form[:city]}
|
||||
label={gettext("City")}
|
||||
required={@member_field_required_map[:city]}
|
||||
/>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email (always required) --%>
|
||||
<div>
|
||||
<%!-- Street and Nr. below --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-64">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
|
|
@ -782,6 +777,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> extract_form_value(form, :house_number, &to_string/1)
|
||||
|> extract_form_value(form, :postal_code, &to_string/1)
|
||||
|> extract_form_value(form, :city, &to_string/1)
|
||||
|> extract_form_value(form, :country, &to_string/1)
|
||||
|> extract_form_value(form, :join_date, &format_date_value/1)
|
||||
|> extract_form_value(form, :exit_date, &format_date_value/1)
|
||||
|> extract_form_value(form, :notes, &to_string/1)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,24 @@
|
|||
>
|
||||
{member.notes}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:country in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_country}
|
||||
field={:country}
|
||||
label={gettext("Country")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.country}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:city in @member_fields_visible}
|
||||
|
|
|
|||
|
|
@ -571,8 +571,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
[street_part, city_part]
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
[member.country, street_part, city_part]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(", ")
|
||||
|> case do
|
||||
"" -> nil
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:street), do: gettext("Street")
|
||||
def label(:house_number), do: gettext("House Number")
|
||||
def label(:postal_code), do: gettext("Postal Code")
|
||||
def label(:country), do: gettext("Country")
|
||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||
def label(:membership_fee_type), do: gettext("Fee Type")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue