Merge branch 'main' into feat/447_concistency
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
carla 2026-02-25 16:52:33 +01:00
commit c7c082b867
27 changed files with 926 additions and 131 deletions

View file

@ -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

View file

@ -10,6 +10,7 @@ defmodule Mv.Constants do
:join_date,
:exit_date,
:notes,
:country,
:city,
:street,
:house_number,

View file

@ -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"
]
}

View file

@ -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)

View file

@ -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" />

View file

@ -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"

View file

@ -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)

View file

@ -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}

View file

@ -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

View file

@ -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")