Polishs import UI closes #337 #398

Merged
carla merged 8 commits from feature/337_polish_import into main 2026-02-04 16:50:45 +01:00
21 changed files with 2128 additions and 1693 deletions

View file

@ -84,6 +84,8 @@ lib/
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource)
│ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource
│ └── email.ex # Email custom type
├── membership_fees/ # MembershipFees domain
│ ├── membership_fees.ex # Domain definition
@ -149,6 +151,8 @@ lib/
│ │ ├── membership_fee_type_live/ # Membership fee type LiveViews
│ │ ├── membership_fee_settings_live.ex # Membership fee settings
│ │ ├── global_settings_live.ex # Global settings
│ │ ├── group_live/ # Group management LiveViews
│ │ ├── import_export_live.ex # CSV import/export LiveView
│ │ └── contribution_type_live/ # Contribution types (mock-up)
│ ├── auth_overrides.ex # AshAuthentication overrides
│ ├── endpoint.ex # Phoenix endpoint
@ -642,7 +646,95 @@ def card(assigns) do
end
```
### 3.3 System Actor Pattern
### 3.3 CSV Import Configuration
**CSV Import Limits:**
CSV import functionality supports configurable limits to prevent resource exhaustion:
```elixir
# config/config.exs
config :mv,
csv_import: [
max_file_size_mb: 10, # Maximum file size in megabytes
max_rows: 1000 # Maximum number of data rows (excluding header)
]
```
**Accessing Configuration:**
Use `Mv.Config` helper functions:
```elixir
# Get max file size in bytes
max_bytes = Mv.Config.csv_import_max_file_size_bytes()
# Get max file size in megabytes
max_mb = Mv.Config.csv_import_max_file_size_mb()
# Get max rows
max_rows = Mv.Config.csv_import_max_rows()
```
**Best Practices:**
- Set reasonable limits based on server resources
- Display limits to users in UI
- Validate file size before upload
- Process imports in chunks (default: 200 rows per chunk)
- Cap error collection (default: 50 errors per import)
### 3.4 Page-Level Authorization
**CheckPagePermission Plug:**
Use `MvWeb.Plugs.CheckPagePermission` for page-level authorization:
```elixir
# lib/mv_web/router.ex
defmodule MvWeb.Router do
use MvWeb, :router
# Add plug to router pipeline
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MvWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MvWeb.Plugs.CheckPagePermission # Page-level authorization
end
end
```
**Permission Set Route Matrix:**
Routes are mapped to permission sets:
- `own_data`: Can access `/profile` and `/members/:id` (own linked member only)
- `read_only`: Can read all data, cannot modify
- `normal_user`: Can read and modify most data
- `admin`: Full access to all routes
**Usage in LiveViews:**
```elixir
# Check page access before mount
def mount(_params, _session, socket) do
actor = current_actor(socket)
if MvWeb.Authorization.can_access_page?(actor, "/admin/roles") do
{:ok, assign(socket, :roles, load_roles(actor))}
else
{:ok, redirect(socket, to: ~p"/")}
end
end
```
**Public Paths:**
Public paths (login, OIDC callbacks) are excluded from permission checks automatically.
### 3.5 System Actor Pattern
**When to Use System Actor:**
@ -727,7 +819,7 @@ Two mechanisms exist for bypassing standard authorization:
**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)
### 3.4 Ash Framework
### 3.6 Ash Framework
**Resource Definition Best Practices:**

View file

@ -15,10 +15,10 @@ This document provides a comprehensive overview of the Mila Membership Managemen
| Metric | Count |
|--------|-------|
| **Tables** | 9 |
| **Tables** | 11 |
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
| **Relationships** | 7 |
| **Indexes** | 20+ |
| **Relationships** | 9 |
| **Indexes** | 25+ |
| **Triggers** | 1 (Full-text search) |
## Tables Overview
@ -77,6 +77,23 @@ This document provides a comprehensive overview of the Mila Membership Managemen
- Membership fee default settings
- Environment variable support for club name
#### `groups`
- **Purpose:** Group definitions for organizing members
- **Rows (Estimated):** Low (typically 5-20 groups per club)
- **Key Features:**
- Unique group names (case-insensitive)
- URL-friendly slugs (auto-generated, immutable)
- Optional descriptions
- Many-to-many relationship with members
#### `member_groups`
- **Purpose:** Join table for many-to-many relationship between members and groups
- **Rows (Estimated):** Medium to High (multiple groups per member)
- **Key Features:**
- Unique constraint on (member_id, group_id)
- CASCADE delete on both sides
- Efficient indexes for queries
### Authorization Domain
#### `roles`
@ -100,6 +117,10 @@ Member (1) → (N) MembershipFeeCycles
MembershipFeeType (1)
Member (N) ←→ (N) Group
↓ ↓
MemberGroups (N) MemberGroups (N)
Settings (1) → MembershipFeeType (0..1)
```
@ -145,6 +166,12 @@ Settings (1) → MembershipFeeType (0..1)
- Settings can reference a default fee type
- `ON DELETE SET NULL` - if fee type is deleted, setting is cleared
9. **Member ↔ Group (N:N via MemberGroup)**
- Many-to-many relationship through `member_groups` join table
- `ON DELETE CASCADE` on both sides - removing member/group removes associations
- Unique constraint on (member_id, group_id) prevents duplicates
- Groups searchable via member search vector
## Important Business Rules
### Email Synchronization
@ -509,7 +536,7 @@ mix run priv/repo/seeds.exs
---
**Last Updated:** 2026-01-13
**Schema Version:** 1.4
**Last Updated:** 2026-01-27
**Schema Version:** 1.5
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)

View file

@ -1752,8 +1752,151 @@ This project demonstrates a modern Phoenix application built with:
---
**Document Version:** 1.4
**Last Updated:** 2026-01-13
---
## Recent Updates (2026-01-13 to 2026-01-27)
### Groups Feature Implementation (2026-01-27)
**PR #378:** *Add groups resource* (closes #371)
- Created `Mv.Membership.Group` resource with name, slug, description
- Created `Mv.Membership.MemberGroup` join table for many-to-many relationship
- Automatic slug generation from name (immutable after creation)
- Case-insensitive name uniqueness via LOWER(name) index
- Database migration: `20260127141620_add_groups_and_member_groups.exs`
**PR #382:** *Groups Admin UI* (closes #372)
- Groups management LiveViews (`/groups`)
- Create, edit, delete groups with confirmation
- Member count display per group
- Add/remove members from groups
- Groups displayed in member overview and detail views
- Filter and sort by groups in member list
**Key Features:**
- Many-to-many relationship: Members can belong to multiple groups
- Groups searchable via member search vector (full-text search)
- CASCADE delete: Removing member/group removes associations
- Unique constraint prevents duplicate member-group associations
### CSV Import Feature Implementation (2026-01-27)
**PR #359:** *Implements CSV Import UI* (closes #335)
- Import/Export LiveView (`/import_export`)
- CSV file upload with auto-upload
- Real-time import progress tracking
- Error and warning reporting
- Chunked processing (200 rows per chunk)
**PR #394:** *Adds config for import limits* (closes #336)
- Configurable maximum file size (default: 10 MB)
- Configurable maximum rows (default: 1000)
- Configuration via `config :mv, csv_import: [max_file_size_mb: ..., max_rows: ...]`
- UI displays limits to users
**PR #395:** *Implements custom field CSV import* (closes #338)
- Support for importing custom field values via CSV
- Custom field mapping by slug or name
- Validation of custom field value types
- Error reporting with line numbers and field names
- CSV templates (German and English) available for download
**Key Features:**
- Member field import (email, first_name, last_name, etc.)
- Custom field value import (all types: string, integer, boolean, date, email)
- Error capping (max 50 errors per import to prevent memory issues)
- Async chunk processing with progress updates
- Admin-only access (requires `:create` permission on Member resource)
### Page Permission Router Plug (2026-01-27)
**PR #390:** *Page Permission Router Plug* (closes #388)
- `MvWeb.Plugs.CheckPagePermission` plug for page-level authorization
- Route-based permission checking
- Automatic redirects for unauthorized access
- Integration with permission sets (own_data, read_only, normal_user, admin)
- Documentation: `docs/page-permission-route-coverage.md`
**Key Features:**
- Page-level access control before LiveView mount
- Permission set-based route matrix
- Redirect targets for different permission levels
- Public paths (login, OIDC callbacks) excluded from checks
### Resource Policies Implementation (2026-01-27)
**PR #387:** *CustomField Resource Policies* (closes #386)
- CustomField resource policies with actor-based authorization
- Admin-only create/update/destroy operations
- Read access for authenticated users
- No system-actor fallback (explicit actor required)
**PR #377:** *CustomFieldValue Resource Policies* (closes #369)
- CustomFieldValue resource policies
- own_data permission set: can create/update own linked member's custom field values
- Admin and normal_user: full access
- Bypass read rule for CustomFieldValue pattern (documented)
**PR #364:** *User Resource Policies* (closes #363)
- User resource policies with scope filtering
- own_data: can read/update own user record
- Admin: full access
- Email change validation for linked members
### System Actor Improvements (2026-01-27)
**PR #379:** *Fix System missing system actor in prod and prevent deletion*
- System actor user creation in migrations
- Block update/destroy on system-actor user
- System user handling in UserLive forms
- Normalize system actor email
**PR #361:** *System Actor Mode for Systemic Flows* (closes #348)
- System actor pattern for systemic operations
- Email synchronization uses system actor
- Cycle generation uses system actor
- Documentation: `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns)
**PR #367:** *Remove NoActor bypass*
- Removed NoActor bypass to prevent masking authorization bugs
- All tests now require explicit actor
- Exception: AshAuthentication bypass tests (conscious exception)
### Email Sync Fixes (2026-01-27)
**PR #380:** *Fix email sync (user->member) when changing password and email*
- Email sync when admin sets password via `admin_set_password`
- Bidirectional email synchronization improvements
- Validation fixes for linked user-member pairs
### UI/UX Improvements (2026-01-27)
**PR #389:** *Change Logo* (closes #385)
- Updated application logo
- Logo display in sidebar and navigation
**PR #362:** *Add boolean custom field filters to member overview* (closes #309)
- Boolean custom field filtering in member list
- Filter by true/false values
- Integration with existing filter system
### Test Performance Optimization (2026-01-27)
**PR #384:** *Minor test refactoring to improve on performance* (closes #383)
- Moved slow tests to nightly test suite
- Optimized policy tests
- Reduced test complexity in seeds tests
- Documentation: `docs/test-performance-optimization.md`
**Key Changes:**
- Fast tests (standard CI): Business logic, validations, data persistence
- Slow tests (nightly): Performance tests, large datasets, query optimization
- UI tests: Basic HTML rendering, navigation, translations
---
**Document Version:** 1.5
**Last Updated:** 2026-01-27
**Maintainer:** Development Team
**Status:** Living Document (update as project evolves)

View file

@ -1,7 +1,7 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
**Last Updated:** 2026-01-13
**Last Updated:** 2026-01-27
**Status:** Active Development
---
@ -29,6 +29,10 @@
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
- ✅ **Secure OIDC email collision handling** (PR #192)
- ✅ **Automatic linking for passwordless users** (PR #192)
- ✅ **Page Permission Router Plug** - Page-level authorization (PR #390, closes #388, 2026-01-27)
- Route-based permission checking
- Automatic redirects for unauthorized access
- Integration with permission sets
**Closed Issues:**
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
@ -55,6 +59,10 @@
- ✅ [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M) - Completed
- ✅ [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M) - Completed
- ✅ [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) - Completed
- ✅ [#388](https://git.local-it.org/local-it/mitgliederverwaltung/issues/388) - Page Permission Router Plug (closed 2026-01-27)
- ✅ [#386](https://git.local-it.org/local-it/mitgliederverwaltung/issues/386) - CustomField Resource Policies (closed 2026-01-27)
- ✅ [#369](https://git.local-it.org/local-it/mitgliederverwaltung/issues/369) - CustomFieldValue Resource Policies (closed 2026-01-27)
- ✅ [#363](https://git.local-it.org/local-it/mitgliederverwaltung/issues/363) - User Resource Policies (closed 2026-01-27)
---
@ -73,9 +81,24 @@
- ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
- ✅ **Groups** - Organize members into groups (PR #378, #382, closes #371, #372, 2026-01-27)
- Many-to-many relationship with groups
- Groups management UI (`/groups`)
- Filter and sort by groups in member list
- Groups displayed in member overview and detail views
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
- Member field import
- Custom field value import
- Real-time progress tracking
- Error reporting
**Closed Issues:**
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
- ✅ [#371](https://git.local-it.org/local-it/mitgliederverwaltung/issues/371) - Add groups resource (closed 2026-01-27)
- ✅ [#372](https://git.local-it.org/local-it/mitgliederverwaltung/issues/372) - Groups Admin UI (closed 2026-01-27)
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
**Open Issues:**
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
@ -88,7 +111,7 @@
- ❌ Advanced filters (date ranges, multiple criteria)
- ❌ Pagination (currently all members loaded)
- ❌ Bulk operations (bulk delete, bulk update)
- ❌ Member import/export (CSV, Excel)
- ❌ Excel import for members
- ❌ Member profile photos/avatars
- ❌ Member history/audit log
- ❌ Duplicate detection
@ -288,12 +311,24 @@
- ✅ **CSV Import Templates** - German and English templates (#329, 2026-01-13)
- Template files in `priv/static/templates/member_import_de.csv` and `member_import_en.csv`
- CSV specification documented in `docs/csv-member-import-v1.md`
- ✅ **CSV Import Implementation** - Full CSV import feature (#335, #336, #338, 2026-01-27)
- Import/Export LiveView (`/import_export`)
- Member field import (email, first_name, last_name, etc.)
- Custom field value import (all types: string, integer, boolean, date, email)
- Real-time progress tracking
- Error and warning reporting with line numbers
- Configurable limits (max file size, max rows)
- Chunked processing (200 rows per chunk)
- Admin-only access
**Closed Issues:**
- ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27)
- ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27)
- ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27)
**Missing Features:**
- ❌ CSV import implementation (templates ready, import logic pending)
- ❌ Excel import for members
- ❌ Import validation and preview
- ❌ Import error handling
- ❌ Import validation preview (before import)
- ❌ Bulk data export
- ❌ Backup export
- ❌ Data migration tools

View file

@ -110,6 +110,7 @@ defmodule MvWeb.Layouts.Sidebar do
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
<.menu_subitem href={~p"/admin/import-export"} label={gettext("Import/Export")} />
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
<% end %>
</.menu_group>

View file

@ -7,7 +7,6 @@ defmodule MvWeb.GlobalSettingsLive do
- Manage custom fields
- Real-time form validation
- Success/error feedback
- CSV member import (admin only)
## Settings
- `club_name` - The name of the association/club (required)
@ -15,47 +14,19 @@ defmodule MvWeb.GlobalSettingsLive do
## Events
- `validate` - Real-time form validation
- `save` - Save settings changes
- `start_import` - Start CSV member import (admin only)
## CSV Import
The CSV import feature allows administrators to upload CSV files and import members.
### File Upload
Files are uploaded automatically when selected (`auto_upload: true`). No manual
upload trigger is required.
### Rate Limiting
Currently, there is no rate limiting for CSV imports. Administrators can start
multiple imports in quick succession. This is intentional for bulk data migration
scenarios, but should be monitored in production.
### Limits
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
- Processing: chunks of 200 rows
- Errors: capped at 50 per import
## Note
Settings is a singleton resource - there is only one settings record.
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
CSV member import has been moved to the Import/Export page (`/admin/import-export`).
"""
use MvWeb, :live_view
alias Mv.Authorization.Actor
alias Mv.Config
alias Mv.Membership
alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
# CSV Import configuration constants
@max_errors 50
@impl true
def mount(_params, session, socket) do
{:ok, settings} = Membership.get_settings()
@ -69,22 +40,8 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
|> assign(:locale, locale)
|> assign(:max_errors, @max_errors)
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
|> assign_form()
# Configure file upload with auto-upload enabled
# Files are uploaded automatically when selected, no need for manual trigger
|> allow_upload(:csv_file,
accept: ~w(.csv),
max_entries: 1,
max_file_size: Config.csv_import_max_file_size_bytes(),
auto_upload: true
)
{:ok, socket}
end
@ -133,211 +90,6 @@ defmodule MvWeb.GlobalSettingsLive do
actor={@current_user}
/>
</.form_section>
<%!-- CSV Import Section (Admin only) --%>
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<.form_section title={gettext("Import Members (CSV)")}>
<div role="note" class="alert alert-info mb-4">
<.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 memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
)}
</p>
<p class="text-sm">
<.link
href="#custom_fields"
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Memberdata")}
</.link>
</p>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</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"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
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>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<label class="label" id="csv_file_help">
<span class="label-text-alt">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</span>
</label>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={
@import_status == :running or
Enum.empty?(@uploads.csv_file.entries) or
@uploads.csv_file.entries |> List.first() |> then(&(&1 && not &1.done?))
}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
<%= if @import_status == :running or @import_status == :done do %>
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done do %>
<section class="space-y-4" data-testid="import-results-panel">
<h2 class="text-lg font-semibold">
{gettext("Import Results")}
</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries",
count: @max_errors
)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div data-testid="import-error-list">
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
<% end %>
</div>
<% end %>
<% end %>
</.form_section>
<% end %>
</Layouts.app>
"""
end
@ -370,115 +122,6 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
@impl true
def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("start_import", _params, socket) do
case check_import_prerequisites(socket) do
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
:ok ->
process_csv_upload(socket)
end
end
# Checks if import can be started (admin permission, status, upload ready)
defp check_import_prerequisites(socket) do
# Ensure user role is loaded before authorization check
user = socket.assigns[:current_user]
user_with_role = Actor.ensure_loaded(user)
cond do
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
{:error, gettext("Only administrators can import members from CSV files.")}
socket.assigns.import_status == :running ->
{:error, gettext("Import is already running. Please wait for it to complete.")}
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
{:error, gettext("Please select a CSV file to import.")}
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
{:error,
gettext("Please wait for the file upload to complete before starting the import.")}
true ->
:ok
end
end
# Processes CSV upload and starts import
defp process_csv_upload(socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
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)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{error}", error: error_message)
)}
end
end
# Starts the import process
defp start_import(socket, import_state) do
progress = initialize_import_progress(import_state)
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, progress)
|> assign(:import_status, :running)
send(self(), {:process_chunk, 0})
{:noreply, socket}
end
# Initializes import progress structure
defp initialize_import_progress(import_state) do
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: length(import_state.chunks),
errors_truncated?: false
}
end
# Formats error messages for display
defp format_error_message(error) do
case error do
%{message: msg} when is_binary(msg) -> msg
%{errors: errors} when is_list(errors) -> inspect(errors)
reason when is_binary(reason) -> reason
other -> inspect(other)
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
@ -558,139 +201,6 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, assign(socket, :settings, updated_settings)}
end
@impl true
def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
if idx >= 0 and idx < length(import_state.chunks) do
start_chunk_processing_task(socket, import_state, progress, idx)
else
handle_chunk_error(socket, :invalid_index, idx)
end
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_done, idx, result}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
handle_chunk_result(socket, import_state, progress, idx, result)
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_error, idx, reason}, socket) do
handle_chunk_error(socket, :processing_failed, idx, reason)
end
# Starts async task to process a chunk
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues
defp start_chunk_processing_task(socket, import_state, progress, idx) do
chunk = Enum.at(import_state.chunks, idx)
# Ensure user role is loaded before using as actor
user = socket.assigns[:current_user]
actor = Actor.ensure_loaded(user)
live_view_pid = self()
# Process chunk with existing error count for capping
opts = [
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
]
# Get locale from socket for translations in background tasks
locale = socket.assigns[:locale] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
if Config.sql_sandbox?() do
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
{:ok, chunk_result} =
MemberCSV.process_chunk(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts
)
# In test mode, send the message - it will be processed when render() is called
# in the test. The test helper wait_for_import_completion() handles message processing
send(live_view_pid, {:chunk_done, idx, chunk_result})
else
# Start async task to process chunk in production
# Use start_child for fire-and-forget: no monitor, no Task messages
# We only use our own send/2 messages for communication
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
# Set locale in task process for translations
Gettext.put_locale(MvWeb.Gettext, locale)
{:ok, chunk_result} =
MemberCSV.process_chunk(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts
)
send(live_view_pid, {:chunk_done, idx, chunk_result})
end)
end
{:noreply, socket}
end
# Handles chunk processing result from async task
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
# Merge progress
new_progress = merge_progress(progress, chunk_result, idx)
socket =
socket
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
# Schedule next chunk or mark as done
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
{:noreply, socket}
end
# Handles chunk processing errors
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
error_message =
case error_type do
:invalid_index ->
gettext("Invalid chunk index: %{idx}", idx: idx)
:missing_state ->
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
:processing_failed ->
gettext("Failed to process chunk %{idx}: %{reason}",
idx: idx,
reason: inspect(reason)
)
end
socket =
socket
|> assign(:import_status, :error)
|> put_flash(:error, error_message)
{:noreply, socket}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(
@ -703,71 +213,4 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: to_form(form))
end
defp consume_and_read_csv(socket) do
result =
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, reason} -> {:error, Exception.message(reason)}
end
end)
result
|> case do
[content] when is_binary(content) ->
{:ok, content}
[{:ok, content}] when is_binary(content) ->
{:ok, content}
[{:error, reason}] ->
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] ->
{:error, gettext("No file was uploaded")}
_other ->
{:error, gettext("Failed to read uploaded file")}
end
end
defp merge_progress(progress, chunk_result, current_chunk_idx) do
# Merge errors with cap of @max_errors overall
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, @max_errors)
errors_truncated? = length(all_errors) > @max_errors
# Merge warnings (optional dedupe - simple append for now)
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
# Update status based on whether we're done
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
}
end
defp schedule_next_chunk(socket, current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks do
# Schedule next chunk
send(self(), {:process_chunk, next_idx})
socket
else
# All chunks processed - status already set to :done in merge_progress
socket
end
end
end

View file

@ -0,0 +1,828 @@
defmodule MvWeb.ImportExportLive do
@moduledoc """
LiveView for importing and exporting members via CSV.
## Features
- CSV member import (admin only)
- Real-time import progress tracking
- Error and warning reporting
- Custom fields support
## CSV Import
The CSV import feature allows administrators to upload CSV files and import members.
### File Upload
Files are uploaded automatically when selected (`auto_upload: true`). No manual
upload trigger is required.
### Rate Limiting
Currently, there is no rate limiting for CSV imports. Administrators can start
multiple imports in quick succession. This is intentional for bulk data migration
scenarios, but should be monitored in production.
### Limits
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
- Processing: chunks of 200 rows
- Errors: capped at 50 per import
"""
use MvWeb, :live_view
alias Mv.Authorization.Actor
alias Mv.Config
alias Mv.Membership
alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
# Maximum number of errors to collect per import to prevent memory issues
# and keep error display manageable. Additional errors are silently dropped
# after this limit is reached.
@max_errors 50
# Maximum length for error messages before truncation
@max_error_message_length 200
@impl true
def mount(_params, session, socket) do
# Get locale from session for translations
locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
# Get club name from settings
club_name =
case Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
socket =
socket
|> assign(:page_title, gettext("Import/Export"))
|> assign(:club_name, club_name)
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
|> assign(:locale, locale)
|> assign(:max_errors, @max_errors)
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
# Configure file upload with auto-upload enabled
# Files are uploaded automatically when selected, no need for manual trigger
|> allow_upload(:csv_file,
accept: ~w(.csv),
max_entries: 1,
max_file_size: Config.csv_import_max_file_size_bytes(),
auto_upload: true
)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@club_name}>
<.header>
{gettext("Import/Export")}
<:subtitle>
{gettext("Import members from CSV files or export member data.")}
</:subtitle>
</.header>
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<%!-- CSV Import Section --%>
<.form_section title={gettext("Import Members (CSV)")}>
{import_info_box(assigns)}
{template_links(assigns)}
{import_form(assigns)}
<%= if @import_status == :running or @import_status == :done do %>
{import_progress(assigns)}
<% end %>
</.form_section>
<%!-- Export Section (Placeholder) --%>
<.form_section title={gettext("Export Members (CSV)")}>
<div role="note" class="alert alert-info">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("Export functionality will be available in a future release.")}
</p>
</div>
</div>
</.form_section>
<% else %>
<div role="alert" class="alert alert-error">
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
<div>
<p>{gettext("You do not have permission to access this page.")}</p>
</div>
</div>
<% end %>
</Layouts.app>
"""
end
# Renders the info box explaining CSV import requirements
defp import_info_box(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<.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."
)}
</p>
<p class="text-sm">
<.link
href={~p"/settings#custom_fields"}
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Member Data")}
</.link>
</p>
</div>
</div>
"""
end
# Renders template download links
defp template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</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"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
"""
end
# Renders the CSV upload form
defp import_form(assigns) do
~H"""
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
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>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
"""
end
# Renders import progress and results
defp import_progress(assigns) do
~H"""
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done do %>
{import_results(assigns)}
<% end %>
</div>
<% end %>
"""
end
# Renders import results summary, errors, and warnings
defp import_results(assigns) do
~H"""
<section class="space-y-4" data-testid="import-results-panel">
<h2 class="text-lg font-semibold">
{gettext("Import Results")}
</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div data-testid="import-error-list">
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning" role="alert">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
"""
end
@impl true
def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("start_import", _params, socket) do
case check_import_prerequisites(socket) do
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
:ok ->
process_csv_upload(socket)
end
end
# Checks if all prerequisites for starting an import are met.
#
# Validates:
# - User has admin permissions
# - No import is currently running
# - CSV file is uploaded and ready
#
# Returns `:ok` if all checks pass, `{:error, message}` otherwise.
#
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
# so ensure_actor_loaded is primarily for clarity.
@spec check_import_prerequisites(Phoenix.LiveView.Socket.t()) ::
:ok | {:error, String.t()}
defp check_import_prerequisites(socket) do
# on_mount already ensures role is loaded, but we keep this for clarity
user_with_role = ensure_actor_loaded(socket)
cond do
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
{:error, gettext("Only administrators can import members from CSV files.")}
socket.assigns.import_status == :running ->
{:error, gettext("Import is already running. Please wait for it to complete.")}
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
{:error, gettext("Please select a CSV file to import.")}
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
{:error,
gettext("Please wait for the file upload to complete before starting the import.")}
true ->
:ok
end
end
# Processes CSV upload and starts import process.
#
# Reads the uploaded CSV file, prepares it for import, and initiates
# the chunked processing workflow.
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp process_csv_upload(socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
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)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: error_message)
)}
end
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()}
defp start_import(socket, import_state) do
progress = initialize_import_progress(import_state)
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, progress)
|> assign(:import_status, :running)
send(self(), {:process_chunk, 0})
{:noreply, socket}
end
# Initializes the import progress tracking structure with default values.
@spec initialize_import_progress(map()) :: map()
defp initialize_import_progress(import_state) do
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: length(import_state.chunks),
errors_truncated?: false
}
end
# Formats error messages for user-friendly display.
#
# Handles various error types including Ash errors, maps with message fields,
# lists of errors, and fallback formatting for unknown types.
@spec format_error_message(any()) :: String.t()
defp format_error_message(error) do
case error do
%Ash.Error.Invalid{} = ash_error ->
format_ash_error(ash_error)
%{message: msg} when is_binary(msg) ->
msg
%{errors: errors} when is_list(errors) ->
format_error_list(errors)
reason when is_binary(reason) ->
reason
other ->
format_unknown_error(other)
end
end
# Formats Ash validation errors for display
defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
defp format_ash_error(error) do
format_unknown_error(error)
end
# Formats a list of errors into a readable string
defp format_error_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
# Formats a single error item
defp format_single_error(error) when is_map(error) do
Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity)
end
defp format_single_error(error) do
to_string(error)
end
# Formats unknown error types with truncation for very long messages
defp format_unknown_error(other) do
error_str = inspect(other, limit: :infinity, pretty: true)
if String.length(error_str) > @max_error_message_length do
String.slice(error_str, 0, @max_error_message_length - 3) <> "..."
else
error_str
end
end
@impl true
def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
if idx < length(import_state.chunks) do
start_chunk_processing_task(socket, import_state, progress, idx)
else
handle_chunk_error(socket, :invalid_index, idx)
end
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_done, idx, result}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
handle_chunk_result(socket, import_state, progress, idx, result)
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_error, idx, reason}, socket) do
handle_chunk_error(socket, :processing_failed, idx, reason)
end
# Processes a chunk with error handling and sends result message to LiveView.
#
# Handles errors from MemberCSV.process_chunk and sends appropriate messages
# to the LiveView process for progress tracking.
@spec process_chunk_with_error_handling(
list(),
map(),
map(),
keyword(),
pid(),
non_neg_integer()
) :: :ok
defp process_chunk_with_error_handling(
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx
) do
result =
try do
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
rescue
e ->
{:error, Exception.message(e)}
catch
:exit, reason ->
{:error, inspect(reason)}
:throw, reason ->
{:error, inspect(reason)}
end
case result do
{:ok, chunk_result} ->
send(live_view_pid, {:chunk_done, idx, chunk_result})
{:error, reason} ->
send(live_view_pid, {:chunk_error, idx, reason})
end
end
# Starts async task to process a chunk of CSV rows.
#
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues.
@spec start_chunk_processing_task(
Phoenix.LiveView.Socket.t(),
map(),
map(),
non_neg_integer()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp start_chunk_processing_task(socket, import_state, progress, idx) do
chunk = Enum.at(import_state.chunks, idx)
actor = ensure_actor_loaded(socket)
live_view_pid = self()
# Process chunk with existing error count for capping
opts = [
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
]
# Get locale from socket for translations in background tasks
locale = socket.assigns[:locale] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
if Config.sql_sandbox?() do
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
# In test mode, send the message - it will be processed when render() is called
# in the test. The test helper wait_for_import_completion() handles message processing
process_chunk_with_error_handling(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
else
# Start async task to process chunk in production
# Use start_child for fire-and-forget: no monitor, no Task messages
# We only use our own send/2 messages for communication
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
# Set locale in task process for translations
Gettext.put_locale(MvWeb.Gettext, locale)
process_chunk_with_error_handling(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
end)
end
{:noreply, socket}
end
# Handles chunk processing result from async task and schedules the next chunk.
@spec handle_chunk_result(
Phoenix.LiveView.Socket.t(),
map(),
map(),
non_neg_integer(),
map()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
# Merge progress
new_progress = merge_progress(progress, chunk_result, idx)
socket =
socket
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
# Schedule next chunk or mark as done
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
{:noreply, socket}
end
# Handles chunk processing errors and updates socket with error status.
@spec handle_chunk_error(
Phoenix.LiveView.Socket.t(),
:invalid_index | :missing_state | :processing_failed,
non_neg_integer(),
any()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
error_message =
case error_type do
:invalid_index ->
gettext("Invalid chunk index: %{idx}", idx: idx)
:missing_state ->
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
:processing_failed ->
gettext("Failed to process chunk %{idx}: %{reason}",
idx: idx,
reason: inspect(reason)
)
end
socket =
socket
|> assign(:import_status, :error)
|> put_flash(:error, error_message)
{:noreply, socket}
end
# Consumes uploaded CSV file entries and reads the file content.
#
# Returns the file content as a binary string or an error tuple.
@spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) ::
{:ok, String.t()} | {:error, String.t()}
defp consume_and_read_csv(socket) do
raw = consume_uploaded_entries(socket, :csv_file, &read_file_entry/2)
case raw do
[{:ok, content}] when is_binary(content) ->
{:ok, content}
# Phoenix LiveView test (render_upload) can return raw content list when callback return is treated as value
[content] when is_binary(content) ->
{:ok, content}
[{:error, reason}] ->
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] ->
{:error, gettext("No file was uploaded")}
_other ->
{:error, gettext("Failed to read uploaded file: unexpected format")}
end
end
# Reads a single file entry from the uploaded path
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
defp read_file_entry(%{path: path}, _entry) do
case File.read(path) do
{:ok, content} ->
{:ok, content}
{:error, reason} when is_atom(reason) ->
# POSIX error atoms (e.g., :enoent) need to be formatted
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
# File.Error struct with reason atom
{:error, :file.format_error(reason)}
{:error, reason} ->
# Fallback for other error types
{:error, Exception.message(reason)}
end
end
# Merges chunk processing results into the overall import progress.
#
# Handles error capping, warning merging, and status updates.
@spec merge_progress(map(), map(), non_neg_integer()) :: map()
defp merge_progress(progress, chunk_result, current_chunk_idx) do
# Merge errors with cap of @max_errors overall
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, @max_errors)
errors_truncated? = length(all_errors) > @max_errors
# Merge warnings (optional dedupe - simple append for now)
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
# Update status based on whether we're done
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
}
end
# Schedules the next chunk for processing or marks import as complete.
@spec schedule_next_chunk(Phoenix.LiveView.Socket.t(), non_neg_integer(), non_neg_integer()) ::
Phoenix.LiveView.Socket.t()
defp schedule_next_chunk(socket, current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks do
# Schedule next chunk
send(self(), {:process_chunk, next_idx})
socket
else
# All chunks processed - status already set to :done in merge_progress
socket
end
end
# Determines if the import button should be disabled based on import status and upload state
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
defp import_button_disabled?(:running, _entries), do: true
defp import_button_disabled?(_status, []), do: true
defp import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
defp import_button_disabled?(_status, _entries), do: false
# Ensures the actor (user with role) is loaded from socket assigns.
#
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
# so this is primarily for clarity and defensive programming.
@spec ensure_actor_loaded(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
defp ensure_actor_loaded(socket) do
user = socket.assigns[:current_user]
# on_mount already ensures role is loaded, but we keep this for clarity
Actor.ensure_loaded(user)
end
end

View file

@ -88,6 +88,9 @@ defmodule MvWeb.Router do
live "/admin/roles/:id", RoleLive.Show, :show
live "/admin/roles/:id/edit", RoleLive.Form, :edit
# Import/Export (Admin only)
live "/admin/import-export", ImportExportLive
post "/set_locale", LocaleController, :set_locale
end

View file

@ -67,7 +67,7 @@ msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
@ -77,12 +77,12 @@ msgstr "Abbrechen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
msgstr "Falsches Passwort. Bitte versuche es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
msgstr "Ungültige Sitzung. Bitte versuche es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
@ -102,32 +102,32 @@ msgstr "Verknüpfen..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
msgstr "Sitzung abgelaufen. Bitte versuche es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
msgstr "Dein OIDC-Konto wurde erfolgreich verknüpft! Du wirst zur Anmeldung weitergeleitet..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
msgstr "Konto aktiviert! Du wirst zur Anmeldung weitergeleitet..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuche es erneut oder kontaktiere den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
msgstr "Die E-Mail-Adresse aus deinem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identity-Provider oder kontaktiere den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktiere den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format

View file

@ -239,27 +239,27 @@ msgstr "Mitglied wurde erfolgreich %{action}"
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "You are now signed in"
msgstr "Sie sind jetzt angemeldet"
msgstr "Du bist jetzt angemeldet"
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "You are now signed out"
msgstr "Sie sind jetzt abgemeldet"
msgstr "Du bist jetzt abgemeldet"
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n"
msgstr "Sie haben sich bereits auf andere Weise angemeldet, aber Ihr Konto noch nicht bestätigt.\nSie können Ihr Konto über den Link bestätigen, den wir Ihnen gesendet haben, oder durch Zurücksetzen Ihres Passworts.\n"
msgstr "Du hast dich bereits auf andere Weise angemeldet, aber dein Konto noch nicht bestätigt.\nDu kannst dein Konto über den Link bestätigen, den wir dir gesendet haben, oder durch Zurücksetzen deines Passworts.\n"
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Your email address has now been confirmed"
msgstr "Ihre E-Mail-Adresse wurde bestätigt"
msgstr "Deine E-Mail-Adresse wurde bestätigt"
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Your password has successfully been reset"
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
@ -399,7 +399,7 @@ msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
@ -439,7 +439,7 @@ msgstr "Administrator*innen-Hinweis"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen."
msgstr "Als Administrator*in kannst du direkt ein neues Passwort für diese*n Benutzer*in setzen."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
@ -454,7 +454,7 @@ msgstr "Passwort ändern"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
msgstr "Aktiviere 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
@ -500,7 +500,7 @@ msgstr "Passwort setzen"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktiviere 'Passwort setzen', um eines hinzuzufügen."
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -570,27 +570,27 @@ msgstr "Vorname"
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "An account with this email already exists. Please verify your password to link your OIDC account."
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifizieren Sie Ihr Passwort, um Ihr OIDC-Konto zu verknüpfen."
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Passwort, um dein OIDC-Konto zu verknüpfen."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Unable to authenticate with OIDC. Please try again."
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."
msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
msgstr "Anmeldung fehlgeschlagen. Bitte versuche es erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication failed. Please try again."
msgstr "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
msgstr "Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider."
msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändere deine E-Mail-Adresse im Identity-Provider."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
@ -668,7 +668,7 @@ msgstr "Einstellungen erfolgreich gespeichert"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen."
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändere bitte zuerst eine der E-Mail-Adressen."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
@ -1072,7 +1072,7 @@ msgstr "Ein Fehler ist aufgetreten"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to delete this cycle?"
msgstr "Möchten Sie diesen Zyklus wirklich löschen?"
msgstr "Möchtest du diesen Zyklus wirklich löschen?"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1092,7 +1092,7 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Click to edit amount"
msgstr "Klicken Sie, um den Betrag zu bearbeiten"
msgstr "Klicke, um den Betrag zu bearbeiten"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1412,7 +1412,7 @@ msgstr "Zahlungsintervall"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please confirm the amount change first"
msgstr "Bitte bestätigen Sie zuerst die Betragsänderung"
msgstr "Bitte bestätige zuerst die Betragsänderung"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1442,7 +1442,7 @@ msgstr "Mitgliedsbeitragsart speichern"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
msgstr "Wählen Sie eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder können nur zwischen Arten mit demselben Intervall wechseln."
msgstr "Wähle eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder können nur zwischen Arten mit demselben Intervall wechseln."
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
@ -1483,12 +1483,12 @@ msgstr "Art"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Type '%{confirmation}' to confirm"
msgstr "Geben Sie '%{confirmation}' ein, um zu bestätigen"
msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr "Verwenden Sie dieses Formular, um Mitgliedsbeitragsarten in Ihrer Datenbank zu verwalten."
msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -1499,7 +1499,7 @@ msgstr "Warnung"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wählen Sie eine Mitgliedsbeitragsart mit demselben Intervall."
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wähle eine Mitgliedsbeitragsart mit demselben Intervall."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1623,7 +1623,7 @@ msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser Rolle noch zugeordnet. Bitte weisen Sie sie zunächst einer anderen Rolle zu."
msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser Rolle noch zugeordnet. Bitte weise sie zunächst einer anderen Rolle zu."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
@ -1746,7 +1746,7 @@ msgstr "Sidebar umschalten"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage roles in your database."
msgstr "Verwenden Sie dieses Formular, um Rollen in Ihrer Datenbank zu verwalten."
msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
@ -1776,7 +1776,7 @@ msgstr "read_only - Lesezugriff auf alle Daten"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to %{action} members."
msgstr "Sie haben keine Berechtigung, Mitglieder zu %{action}."
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -1821,22 +1821,22 @@ msgstr "Benutzer*in nicht gefunden"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
msgstr "Sie haben keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this user"
msgstr "Sie haben keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr "Sie haben keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user"
msgstr "Sie haben keine Berechtigung, diese*n Benutzer*in zu löschen"
msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1848,7 +1848,7 @@ msgstr "erstellt"
msgid "updated"
msgstr "aktualisiert"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
@ -1867,12 +1867,12 @@ msgstr "Mitglied nicht gefunden"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this member"
msgstr "Sie haben keine Berechtigung, auf dieses Mitglied zuzugreifen"
msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr "Sie haben keine Berechtigung, dieses Mitglied zu löschen"
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen"
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
@ -1922,17 +1922,17 @@ msgstr "Fehler beim %{action} des Mitglieds."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Failed to save member. Please try again."
msgstr "Fehler beim Speichern des Mitglieds. Bitte versuchen Sie es erneut."
msgstr "Fehler beim Speichern des Mitglieds. Bitte versuche es erneut."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Please correct the errors in the form and try again."
msgstr "Bitte korrigieren Sie die Fehler im Formular und versuchen Sie es erneut."
msgstr "Bitte korrigiere die Fehler im Formular und versuche es erneut."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Validation failed. Please check your input."
msgstr "Validierung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingabe."
msgstr "Validierung fehlgeschlagen. Bitte überprüfe deine Eingabe."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
@ -1969,147 +1969,137 @@ msgstr "Bezahlstatus"
msgid "Reset"
msgstr "Zurücksetzen"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
msgstr " (Datenfeld: %{field})"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
msgstr "CSV Datei"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Download CSV templates:"
msgstr "CSV Vorlagen herunterladen:"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "English Template"
msgstr "Englische Vorlage"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries"
msgstr "Liste der Fehler auf %{count} Einträge reduziert"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Errors"
msgstr "Fehler"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{error}"
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{reason}"
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{reason}"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}"
msgstr "Das Importieren von %{idx} ist gescheitert: %{reason}"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read file: %{reason}"
msgstr "Fehler beim Lesen der Datei: %{reason}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file"
msgstr "Fehler beim Lesen der hochgeladenen Datei"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)"
msgstr "Fehlgeschlagen: %{count} Zeile(n)"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "German Template"
msgstr "Deutsche Vorlage"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
msgstr "Import-Ergebnisse"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import is already running. Please wait for it to complete."
msgstr "Import läuft bereits. Bitte warten Sie, bis er abgeschlossen ist."
msgstr "Import läuft bereits. Bitte warte, bis er abgeschlossen ist."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}"
msgstr "Ungültiger Chunk-Index: %{idx}"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}"
msgstr "Zeile %{line}: %{message}"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "No file was uploaded"
msgstr "Es wurde keine Datei hochgeladen"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can import members from CSV files."
msgstr "Nur Administrator*innen können Mitglieder aus CSV-Dateien importieren."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Please select a CSV file to import."
msgstr "Bitte wählen Sie eine CSV-Datei zum Importieren."
msgstr "Bitte wähle eine CSV-Datei zum Importieren."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Please wait for the file upload to complete before starting the import."
msgstr "Bitte warten Sie, bis der Datei-Upload abgeschlossen ist, bevor Sie den Import starten."
msgstr "Bitte warte, bis der Datei-Upload abgeschlossen ist, bevor du den Import startest."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..."
msgstr "Verarbeite Chunk %{current} von %{total}..."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Start Import"
msgstr "Import starten"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Starting import..."
msgstr "Import wird gestartet..."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)"
msgstr "Erfolgreich eingefügt: %{count} Mitglied(er)"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Summary"
msgstr "Zusammenfassung"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Warnings"
msgstr "Warnungen"
@ -2255,9 +2245,9 @@ msgstr "Nicht berechtigt."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfen Sie Ihre Berechtigungen."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB"
msgstr "Nur CSV Dateien, maximal %{size} MB"
@ -2282,20 +2272,51 @@ msgstr "Datenfeld: %{name} erwartet %{type} %{details}, erhalten: %{value}"
msgid "custom_field: %{name} expected %{type}, got: %{value}"
msgstr "Datenfeld: %{name} erwartet %{type}, erhalten: %{value}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Manage Memberdata"
msgstr "Mitgliederdaten verwalten"
#: lib/mv_web/live/global_settings_live.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, so they must be listed in the list of memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. Sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld ist, erstellen Sie es in Mila vor dem Import."
msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld ist, erstelle es in Mila vor dem Import."
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export Members (CSV)"
msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Export functionality will be available in a future release."
msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read uploaded file: unexpected format"
msgstr "Fehler beim Lesen der hochgeladenen Datei"
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import members from CSV files or export member data."
msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import/Export"
msgstr "Import/Export"
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this page."
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Manage Member Data"
msgstr "Mitgliederdaten verwalten"
#: lib/mv_web/live/import_export_live.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, 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."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
@ -2343,3 +2364,8 @@ msgstr "SSO-/OIDC-Benutzer*in"
#, elixir-autogen, elixir-format, fuzzy
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr "Dieser*e Benutzer*in ist per SSO (Single Sign-On) angebunden. Ein hier gesetztes oder geändertes Passwort betrifft nur die Anmeldung mit E-Mail und Passwort in dieser Anwendung. Es ändert nicht das Passwort beim Identity-Provider (z. B. Authentik). Zum Ändern des SSO-Passworts nutzen Sie den Identity-Provider oder die IT Ihrer Organisation."
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr "Nur Administrator*innen können Zyklen regenerieren"

View file

@ -123,7 +123,7 @@ msgstr "muss vorhanden sein"
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr "Benutzer*in hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied."
msgstr "Benutzer*in hat bereits ein Mitglied. Entferne zuerst das vorhandene Mitglied."
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten"

View file

@ -1849,7 +1849,7 @@ msgstr ""
msgid "updated"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
@ -1970,147 +1970,137 @@ msgstr ""
msgid "Reset"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Download CSV templates:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "English Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Errors"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{error}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read file: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "German Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import is already running. Please wait for it to complete."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "No file was uploaded"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can import members from CSV files."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Please select a CSV file to import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Please wait for the file upload to complete before starting the import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Start Import"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Starting import..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Summary"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Warnings"
msgstr ""
@ -2258,7 +2248,7 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "CSV files only, maximum %{size} MB"
msgstr ""
@ -2283,21 +2273,52 @@ msgstr ""
msgid "custom_field: %{name} expected %{type}, got: %{value}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Manage Memberdata"
msgstr ""
#: lib/mv_web/live/global_settings_live.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, so they must be listed in the list of memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Export Members (CSV)"
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Export functionality will be available in a future release."
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file: unexpected format"
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import members from CSV files or export member data."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import/Export"
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this page."
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Manage Member Data"
msgstr ""
#: lib/mv_web/live/import_export_live.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, 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."
msgstr ""
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users"

View file

@ -1849,7 +1849,7 @@ msgstr ""
msgid "updated"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Unknown error"
@ -1970,147 +1970,137 @@ msgstr ""
msgid "Reset"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Download CSV templates:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "English Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Errors"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{error}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to prepare CSV import: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read file: %{reason}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to read uploaded file"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "German Template"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import is already running. Please wait for it to complete."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "No file was uploaded"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Only administrators can import members from CSV files."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Please select a CSV file to import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Please wait for the file upload to complete before starting the import."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Start Import"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Starting import..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Summary"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Warnings"
msgstr ""
@ -2258,7 +2248,7 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB"
msgstr ""
@ -2283,21 +2273,52 @@ msgstr ""
msgid "custom_field: %{name} expected %{type}, got: %{value}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Manage Memberdata"
msgstr ""
#: lib/mv_web/live/global_settings_live.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, so they must be listed in the list of memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
msgstr "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export Members (CSV)"
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Export functionality will be available in a future release."
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read uploaded file: unexpected format"
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import members from CSV files or export member data."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format
msgid "Import/Export"
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this page."
msgstr ""
#: lib/mv_web/live/import_export_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Manage Member Data"
msgstr ""
#: lib/mv_web/live/import_export_live.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, 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."
msgstr ""
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users"
@ -2344,3 +2365,8 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr ""

View file

@ -41,18 +41,6 @@ defmodule Mv.Accounts.UserAuthenticationTest do
assert is_nil(found_user.oidc_id)
end
@tag :test_proposal
test "password authentication uses email as identity_field" do
# Verify the configuration: password strategy should use email as identity_field
# This test checks the AshAuthentication configuration
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
assert password_strategy != nil
assert password_strategy.identity_field == :email
end
@tag :test_proposal
test "multiple users can exist with different emails" do
user1 =

View file

@ -1,13 +1,14 @@
defmodule Mv.Membership.CustomFieldSlugTest do
@moduledoc """
Tests for automatic slug generation on CustomField resource.
Tests for CustomField slug business rules only.
This test suite verifies:
1. Slugs are automatically generated from the name attribute
2. Slugs are unique (cannot have duplicates)
3. Slugs are immutable (don't change when name changes)
4. Slugs handle various edge cases (unicode, special chars, etc.)
5. Slugs can be used for lookups
We test our business logic, not Ash/slugify implementation details:
- Slug is generated from name on create (one smoke test)
- Slug is unique (business rule)
- Slug is immutable (does not change when name is updated; cannot be set manually)
- Slug cannot be empty (rejects name with only special characters)
We do not test: slugify edge cases (umlauts, truncation, etc.) or Ash/Ecto struct/load behavior.
"""
use Mv.DataCase, async: true
@ -18,8 +19,8 @@ defmodule Mv.Membership.CustomFieldSlugTest do
%{actor: system_actor}
end
describe "automatic slug generation on create" do
test "generates slug from name with simple ASCII text", %{actor: actor} do
describe "slug generation (business rule)" do
test "slug is generated from name on create", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -30,78 +31,6 @@ defmodule Mv.Membership.CustomFieldSlugTest do
assert custom_field.slug == "mobile-phone"
end
test "generates slug from name with German umlauts", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Café Müller",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "cafe-muller"
end
test "generates slug with lowercase conversion", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "TEST NAME",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "test-name"
end
test "generates slug by removing special characters", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "E-Mail & Address!",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "e-mail-address"
end
test "generates slug by replacing multiple spaces with single hyphen", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Multiple Spaces",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "multiple-spaces"
end
test "trims leading and trailing hyphens", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "-Test-",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "test"
end
test "handles unicode characters properly (ß becomes ss)", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Straße",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "strasse"
end
end
describe "slug uniqueness" do
@ -248,29 +177,8 @@ defmodule Mv.Membership.CustomFieldSlugTest do
end
end
describe "slug edge cases" do
test "handles very long names by truncating slug", %{actor: actor} do
# Create a name at the maximum length (100 chars)
long_name = String.duplicate("abcdefghij", 10)
# 100 characters exactly
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: long_name,
value_type: :string
})
|> Ash.create(actor: actor)
# Slug should be truncated to maximum 100 characters
assert String.length(custom_field.slug) <= 100
# Should be the full slugified version since name is exactly 100 chars
assert custom_field.slug == long_name
end
describe "slug cannot be empty (business rule)" do
test "rejects name with only special characters", %{actor: actor} do
# When name contains only special characters, slug would be empty
# This should fail validation
assert {:error, %Ash.Error.Invalid{} = error} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -279,107 +187,9 @@ defmodule Mv.Membership.CustomFieldSlugTest do
})
|> Ash.create(actor: actor)
# Should fail because slug would be empty
error_message = Exception.message(error)
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
end
test "handles mixed special characters and text", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test@#$%Name",
value_type: :string
})
|> Ash.create(actor: actor)
# slugify keeps the hyphen between words
assert custom_field.slug == "test-name"
end
test "handles numbers in name", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Field 123 Test",
value_type: :string
})
|> Ash.create(actor: actor)
assert custom_field.slug == "field-123-test"
end
test "handles consecutive hyphens in name", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test---Name",
value_type: :string
})
|> Ash.create(actor: actor)
# Should reduce multiple hyphens to single hyphen
assert custom_field.slug == "test-name"
end
test "handles name with dots and underscores", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test.field_name",
value_type: :string
})
|> Ash.create(actor: actor)
# Dots and underscores should be handled (either kept or converted)
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
end
end
describe "slug in queries and responses" do
test "slug is included in struct after create", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create(actor: actor)
# Slug should be present in the struct
assert Map.has_key?(custom_field, :slug)
assert custom_field.slug != nil
end
test "can load custom field and slug is present", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create(actor: actor)
# Load it back
loaded_custom_field = Ash.get!(CustomField, custom_field.id, actor: actor)
assert loaded_custom_field.slug == "test"
end
test "slug is returned in list queries", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create(actor: actor)
custom_fields = Ash.read!(CustomField, actor: actor)
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
assert found.slug == "test"
end
end
describe "slug-based lookup (future feature)" do

View file

@ -2,7 +2,7 @@ defmodule Mv.Membership.GroupTest do
@moduledoc """
Tests for Group resource validations, CRUD operations, and relationships.
"""
use Mv.DataCase, async: true
use Mv.DataCase, async: false
alias Mv.Membership
@ -232,23 +232,7 @@ defmodule Mv.Membership.GroupTest do
end
describe "Relationships & Deletion" do
test "group has many_to_many members relationship (load with preloading)", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Load group with members
{:ok, group_with_members} =
Ash.load(group, :members, actor: actor, domain: Mv.Membership)
assert length(group_with_members.members) == 1
assert hd(group_with_members.members).id == member.id
end
# We test business/data rules (CASCADE), not Ash relationship loading (framework).
test "delete group cascades to member_groups (members remain intact)", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)

View file

@ -2,7 +2,7 @@ defmodule Mv.Membership.MemberGroupTest do
@moduledoc """
Tests for MemberGroup join table resource - validations and cascade delete behavior.
"""
use Mv.DataCase, async: true
use Mv.DataCase, async: false
alias Mv.Membership

View file

@ -1,6 +1,10 @@
defmodule Mv.MembershipFees.MembershipFeeTypeTest do
@moduledoc """
Tests for MembershipFeeType resource.
Tests for MembershipFeeType business rules only.
We test: required fields, allowed interval values, uniqueness, amount constraints,
interval immutability, and referential integrity (cannot delete when in use).
We do not test: standard CRUD (create/update/delete when no constraints apply).
"""
use Mv.DataCase, async: true
@ -11,34 +15,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
%{actor: system_actor}
end
describe "create MembershipFeeType" do
test "can create membership fee type with valid attributes", %{actor: actor} do
attrs = %{
name: "Standard Membership",
amount: Decimal.new("120.00"),
interval: :yearly,
description: "Standard yearly membership fee"
}
assert {:ok, %MembershipFeeType{} = fee_type} =
Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.name == "Standard Membership"
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
assert fee_type.interval == :yearly
assert fee_type.description == "Standard yearly membership fee"
end
test "can create membership fee type without description", %{actor: actor} do
attrs = %{
name: "Basic",
amount: Decimal.new("60.00"),
interval: :monthly
}
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs, actor: actor)
end
describe "create MembershipFeeType - business rules" do
test "requires name", %{actor: actor} do
attrs = %{
amount: Decimal.new("100.00"),
@ -69,28 +46,24 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
assert error_on_field?(error, :interval)
end
test "validates interval enum values - monthly", %{actor: actor} do
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :monthly
end
test "accepts valid interval values (monthly, quarterly, half_yearly, yearly)", %{
actor: actor
} do
for {interval, name} <- [
monthly: "Monthly",
quarterly: "Quarterly",
half_yearly: "Half Yearly",
yearly: "Yearly"
] do
attrs = %{
name: "#{name} #{System.unique_integer([:positive])}",
amount: Decimal.new("10.00"),
interval: interval
}
test "validates interval enum values - quarterly", %{actor: actor} do
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :quarterly
assert fee_type.interval == interval
end
test "validates interval enum values - half_yearly", %{actor: actor} do
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :half_yearly
end
test "validates interval enum values - yearly", %{actor: actor} do
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :yearly
end
test "rejects invalid interval values", %{actor: actor} do
@ -128,13 +101,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
end
end
describe "update MembershipFeeType" do
describe "update MembershipFeeType - business rules" do
setup %{actor: actor} do
{:ok, fee_type} =
Ash.create(
MembershipFeeType,
%{
name: "Original Name",
name: "Original Name #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
@ -145,28 +118,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
%{fee_type: fee_type}
end
test "can update name", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
assert updated.name == "Updated Name"
end
test "can update amount", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
end
test "can update description", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} =
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
assert updated.description == "Updated description"
end
test "can clear description", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: nil}, actor: actor)
assert updated.description == nil
end
test "interval immutability: update fails when interval is changed", %{
actor: actor,
fee_type: fee_type
@ -179,7 +130,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
end
end
describe "delete MembershipFeeType" do
describe "delete MembershipFeeType - business rules (referential integrity)" do
setup %{actor: actor} do
{:ok, fee_type} =
Ash.create(
@ -195,12 +146,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
%{fee_type: fee_type}
end
test "can delete when not in use", %{actor: actor, fee_type: fee_type} do
result = Ash.destroy(fee_type, actor: actor)
# Ash.destroy returns :ok or {:ok, _} depending on version
assert result == :ok or match?({:ok, _}, result)
end
test "cannot delete when members are assigned", %{actor: actor, fee_type: fee_type} do
alias Mv.Membership.Member

View file

@ -39,9 +39,10 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
original_config = Application.get_env(:mv, :csv_import, [])
try do
# Arrange: Set custom row limit to 500
Application.put_env(:mv, :csv_import, max_rows: 500)
{:ok, view, _html} = live(conn, ~p"/settings")
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Generate CSV with 501 rows (exceeding custom limit of 500)
header = "first_name;last_name;email;street;postal_code;city\n"
@ -53,17 +54,17 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
large_csv = header <> Enum.join(rows)
# Simulate file upload using helper function
# Act: Upload CSV and submit form
upload_csv_file(view, large_csv, "too_many_rows_custom.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Assert: Import should be rejected with error message
html = render(view)
# Business rule: import should be rejected when exceeding configured limit
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or
html =~ "Failed to prepare"
assert html =~ "Failed to prepare CSV import"
after
# Restore original config
Application.put_env(:mv, :csv_import, original_config)

View file

@ -3,22 +3,6 @@ defmodule MvWeb.GlobalSettingsLiveTest do
import Phoenix.LiveViewTest
alias Mv.Membership
# Helper function to upload CSV file in tests
# Reduces code duplication across multiple test cases
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: filename,
content: csv_content,
size: byte_size(csv_content),
type: "text/csv"
}
])
|> render_upload(filename)
end
describe "Global Settings LiveView" do
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
@ -97,595 +81,4 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert render(view) =~ "updated" or render(view) =~ "success"
end
end
describe "CSV Import Section" do
test "admin user sees import section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check for import section heading or identifier
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
end
test "admin user sees custom fields notice", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check for custom fields notice text
assert html =~ "Use the data field name"
end
test "admin user sees template download links", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
html = render(view)
# Check for English template link
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
# Check for German template link
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
end
test "template links use static path helper", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
html = render(view)
# Check that links contain the static path pattern
# Static paths typically start with /templates/ or contain the full path
assert html =~ "/templates/member_import_en.csv" or
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
assert html =~ "/templates/member_import_de.csv" or
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
end
test "admin user sees file upload input", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
html = render(view)
# Check for file input element
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
end
test "file upload has CSV-only restriction", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
html = render(view)
# Check for CSV file type restriction in help text or accept attribute
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
end
test "non-admin user does not see import section", %{conn: conn} do
# Member (own_data) is redirected when accessing /settings (no page permission)
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
assert to == "/users/#{member_user.id}"
end
end
describe "CSV Import - Import" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
end
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
# Trigger start_import event via form submit
assert view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check that import has started or shows appropriate message
html = render(view)
# Either import started successfully OR we see a specific error (not admin error)
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
no_admin_error = not (html =~ "Only administrators can import")
# If import failed, it should be a CSV parsing error, not an admin error
if html =~ "Failed to prepare CSV import" do
# This is acceptable - CSV might have issues, but admin check passed
assert no_admin_error
else
# Import should have started
assert import_started or html =~ "CSV File"
end
end
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check that import has started or shows appropriate message
html = render(view)
# Either import started successfully OR we see a specific error (not admin error)
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
no_admin_error = not (html =~ "Only administrators can import")
# If import failed, it should be a CSV parsing error, not an admin error
if html =~ "Failed to prepare CSV import" do
# This is acceptable - CSV might have issues, but admin check passed
assert no_admin_error
else
# Import should have started
assert import_started or html =~ "CSV File"
end
end
test "non-admin cannot start import", %{conn: conn} do
# Member (own_data) is redirected when accessing /settings (no page permission)
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
assert to == "/users/#{member_user.id}"
end
test "invalid CSV shows user-friendly error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Create invalid CSV (missing required fields)
invalid_csv = "invalid_header\nincomplete_row"
# Simulate file upload using helper function
upload_csv_file(view, invalid_csv, "invalid.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check for error message (flash)
html = render(view)
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
end
@tag :skip
test "empty CSV shows error", %{conn: conn} do
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
# The error is handled correctly in production, but test framework has limitations
{:ok, view, _html} = live(conn, ~p"/settings")
empty_csv = " "
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
File.write!(csv_path, empty_csv)
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: "empty.csv",
content: empty_csv,
size: byte_size(empty_csv),
type: "text/csv"
}
])
|> render_upload("empty.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check for error message
html = render(view)
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
end
end
describe "CSV Import - Step 3: Chunk Processing" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
valid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
# Read invalid CSV fixture
invalid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|> File.read!()
{:ok,
conn: conn,
admin_user: admin_user,
valid_csv_content: valid_csv_content,
invalid_csv_content: invalid_csv_content}
end
test "happy path: valid CSV processes all chunks and shows done status", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing to complete
# In test mode, chunks are processed synchronously and messages are sent via send/2
# render(view) processes handle_info messages, so we call it multiple times
# to ensure all messages are processed
# Use the same approach as "success rendering" test which works
Process.sleep(1000)
html = render(view)
# Should show success count (inserted count)
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
# Should show completed status
assert html =~ "completed" or html =~ "done" or html =~ "Import completed" or
has_element?(view, "[data-testid='import-results-panel']")
end
test "error handling: invalid CSV shows errors with line numbers", %{
conn: conn,
invalid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "invalid_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for chunk processing
Process.sleep(500)
html = render(view)
# Should show failure count > 0
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
# Should show line numbers in errors (from service, not recalculated)
# Line numbers should be 2, 3 (header is line 1)
assert html =~ "2" or html =~ "3" or html =~ "line"
end
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Generate CSV with 100 invalid rows (all missing email)
header = "first_name;last_name;email;street;postal_code;city\n"
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
large_invalid_csv = header <> Enum.join(invalid_rows)
# Simulate file upload using helper function
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for chunk processing
Process.sleep(1000)
html = render(view)
# Should show failed count == 100
assert html =~ "100" or html =~ "failed"
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
# The important thing is that processing completes without crashing
assert html =~ "done" or html =~ "complete" or html =~ "finished"
end
test "chunk scheduling: progress updates show chunk processing", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait a bit for processing to start
Process.sleep(200)
# Check that status area exists (with aria-live for accessibility)
html = render(view)
assert html =~ "aria-live" or html =~ "status" or html =~ "progress" or
html =~ "Processing" or html =~ "chunk"
# Final state should be :done
Process.sleep(500)
final_html = render(view)
assert final_html =~ "done" or final_html =~ "complete" or final_html =~ "finished"
end
end
describe "CSV Import - Step 4: Results UI" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
valid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
# Read invalid CSV fixture
invalid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|> File.read!()
# Read CSV with unknown custom field
unknown_custom_field_csv =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|> File.read!()
{:ok,
conn: conn,
admin_user: admin_user,
valid_csv_content: valid_csv_content,
invalid_csv_content: invalid_csv_content,
unknown_custom_field_csv: unknown_custom_field_csv}
end
test "success rendering: valid CSV shows success count", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing to complete
Process.sleep(1000)
html = render(view)
# Should show success count (inserted count)
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
# Should show completed status
assert html =~ "completed" or html =~ "done" or html =~ "Import completed"
end
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
conn: conn,
invalid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "invalid_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
html = render(view)
# Should show failure count
assert html =~ "Failed" or html =~ "failed"
# Should show error list with line numbers (from service, not recalculated)
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
# Should show error messages
assert html =~ "error" or html =~ "Error" or html =~ "Errors"
end
test "warning rendering: CSV with unknown custom field shows warnings block", %{
conn: conn,
unknown_custom_field_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/settings")
csv_path =
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
File.write!(csv_path, csv_content)
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: "unknown_custom.csv",
content: csv_content,
size: byte_size(csv_content),
type: "text/csv"
}
])
|> render_upload("unknown_custom.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
html = render(view)
# Should show warnings block (if warnings were generated)
# Warnings are generated when unknown custom field columns are detected
# Check if warnings section exists OR if import completed successfully
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
import_completed = html =~ "completed" or html =~ "done" or html =~ "Import Results"
# If warnings exist, they should contain the column name
if has_warnings do
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
html =~ "will be ignored"
end
# Import should complete (either with or without warnings)
assert import_completed
end
test "A11y: file input has label", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check for label associated with file input
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
html =~ ~r/<label[^>]*>.*CSV File/i
end
test "A11y: status/progress container has aria-live", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
html = render(view)
# Check for aria-live attribute in status area
assert html =~ ~r/aria-live=["']polite["']/i
end
test "A11y: links have descriptive text", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check that links have descriptive text (not just "click here")
# Template links should have text like "English Template" or "German Template"
assert html =~ "English Template" or html =~ "German Template" or
html =~ "English" or html =~ "German"
# Custom Fields section should have descriptive text (Data Field button)
# The component uses "New Data Field" button, not a link
assert html =~ "Data Field" or html =~ "New Data Field"
end
end
describe "CSV Import - Step 5: Edge Cases" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Read CSV with BOM
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|> File.read!()
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "bom_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
html = render(view)
# Should succeed (BOM is stripped automatically)
assert html =~ "completed" or html =~ "done" or html =~ "Inserted"
# Should not show error about BOM
refute html =~ "BOM" or html =~ "encoding"
end
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|> File.read!()
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "empty_lines.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
html = render(view)
# Should show error with correct line number (line 4, not line 3)
# The error should be on the line with invalid email, which is after the empty line
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
# Should show error message
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
end
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Generate CSV with 1001 rows dynamically
header = "first_name;last_name;email;street;postal_code;city\n"
rows =
for i <- 1..1001 do
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
end
large_csv = header <> Enum.join(rows)
# Simulate file upload using helper function
upload_csv_file(view, large_csv, "too_many_rows.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
html = render(view)
# Should show user-friendly error about row limit
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
html =~ "Failed to prepare"
end
test "wrong file type (.txt): upload shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Create .txt file (not .csv)
txt_content = "This is not a CSV file\nJust some text\n"
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
File.write!(txt_path, txt_content)
# Try to upload .txt file
# Note: allow_upload is configured to accept only .csv, so this should fail
# In tests, we can't easily simulate file type rejection, but we can check
# that the UI shows appropriate help text
html = render(view)
# Should show CSV-only restriction in help text
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
end
test "file input has correct accept attribute for CSV only", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check that file input has accept attribute for CSV
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
end
end
end

View file

@ -0,0 +1,669 @@
defmodule MvWeb.ImportExportLiveTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
# Helper function to upload CSV file in tests
# Reduces code duplication across multiple test cases
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: filename,
content: csv_content,
size: byte_size(csv_content),
type: "text/csv"
}
])
|> render_upload(filename)
end
describe "Import/Export LiveView" do
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "renders the import/export page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
assert html =~ "Import/Export"
end
test "displays import section for admin user", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
assert html =~ "Import Members (CSV)"
end
test "displays export section placeholder", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
assert html =~ "Export Members (CSV)" or html =~ "Export"
end
end
describe "CSV Import Section" do
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "admin user sees import section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check for import section heading or identifier
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
end
test "admin user sees custom fields notice", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check for custom fields notice text
assert html =~ "Use the data field name"
end
test "admin user sees template download links", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for English template link
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
# Check for German template link
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
end
test "template links use static path helper", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check that links contain the static path pattern
# Static paths typically start with /templates/ or contain the full path
assert html =~ "/templates/member_import_en.csv" or
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
assert html =~ "/templates/member_import_de.csv" or
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
end
test "admin user sees file upload input", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for file input element
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
end
test "file upload has CSV-only restriction", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for CSV file type restriction in help text or accept attribute
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
end
test "non-admin user sees permission error", %{conn: conn} do
# Member (own_data) user
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
# Router plug redirects non-admin users before LiveView loads
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
live(conn, ~p"/admin/import-export")
# Should redirect to user profile page
assert redirect_path =~ "/users/"
# Should show permission error in flash
assert error_message =~ "don't have permission"
end
end
describe "CSV Import - Import" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
end
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
# Trigger start_import event via form submit
assert view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check that import has started using data-testid
# Either import-progress-container exists (import started) OR we see a CSV error
html = render(view)
import_started = has_element?(view, "[data-testid='import-progress-container']")
no_admin_error = not (html =~ "Only administrators can import")
# If import failed, it should be a CSV parsing error, not an admin error
if html =~ "Failed to prepare CSV import" do
# This is acceptable - CSV might have issues, but admin check passed
assert no_admin_error
else
# Import should have started - check for progress container
assert import_started
end
end
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check that import has started using data-testid
html = render(view)
import_started = has_element?(view, "[data-testid='import-progress-container']")
no_admin_error = not (html =~ "Only administrators can import")
# If import failed, it should be a CSV parsing error, not an admin error
if html =~ "Failed to prepare CSV import" do
# This is acceptable - CSV might have issues, but admin check passed
assert no_admin_error
else
# Import should have started - check for progress container
assert import_started
end
end
test "non-admin cannot start import", %{conn: conn} do
# Member (own_data) user
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
# Router plug redirects non-admin users before LiveView loads
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
live(conn, ~p"/admin/import-export")
# Should redirect to user profile page
assert redirect_path =~ "/users/"
# Should show permission error in flash
assert error_message =~ "don't have permission"
end
test "invalid CSV shows user-friendly error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Create invalid CSV (missing required fields)
invalid_csv = "invalid_header\nincomplete_row"
# Simulate file upload using helper function
upload_csv_file(view, invalid_csv, "invalid.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check for error message (flash)
html = render(view)
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
end
@tag :skip
test "empty CSV shows error", %{conn: conn} do
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
# The error is handled correctly in production, but test framework has limitations
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
empty_csv = " "
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
File.write!(csv_path, empty_csv)
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: "empty.csv",
content: empty_csv,
size: byte_size(empty_csv),
type: "text/csv"
}
])
|> render_upload("empty.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check for error message
html = render(view)
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
end
end
describe "CSV Import - Step 3: Chunk Processing" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
valid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
# Read invalid CSV fixture
invalid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|> File.read!()
{:ok,
conn: conn,
admin_user: admin_user,
valid_csv_content: valid_csv_content,
invalid_csv_content: invalid_csv_content}
end
test "happy path: valid CSV processes all chunks and shows done status", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing to complete
# In test mode, chunks are processed synchronously and messages are sent via send/2
# render(view) processes handle_info messages, so we call it multiple times
# to ensure all messages are processed
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
# Verify success count is shown
html = render(view)
assert html =~ "Successfully inserted" or html =~ "inserted"
end
test "error handling: invalid CSV shows errors with line numbers", %{
conn: conn,
invalid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "invalid_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for chunk processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed with errors)
assert has_element?(view, "[data-testid='import-results-panel']")
# Check that error list exists
assert has_element?(view, "[data-testid='import-error-list']")
html = render(view)
# Should show failure count > 0
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
# Should show line numbers in errors (from service, not recalculated)
# Line numbers should be 2, 3 (header is line 1)
assert html =~ "2" or html =~ "3" or html =~ "line"
end
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Generate CSV with 100 invalid rows (all missing email)
header = "first_name;last_name;email;street;postal_code;city\n"
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
large_invalid_csv = header <> Enum.join(invalid_rows)
# Simulate file upload using helper function
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for chunk processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
html = render(view)
# Should show failed count == 100
assert html =~ "100" or html =~ "failed"
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
# The important thing is that processing completes without crashing
# Import is done when import-results-panel exists
end
test "chunk scheduling: progress updates show chunk processing", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# In test mode chunks run synchronously, so we may already be :done when we check.
# Accept either progress container (if we caught :running) or results panel (if already :done).
_html = render(view)
assert has_element?(view, "[data-testid='import-progress-container']") or
has_element?(view, "[data-testid='import-results-panel']")
# Wait for final state and assert results panel is shown
Process.sleep(500)
assert has_element?(view, "[data-testid='import-results-panel']")
end
end
describe "CSV Import - Step 4: Results UI" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
valid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
# Read invalid CSV fixture
invalid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|> File.read!()
# Read CSV with unknown custom field
unknown_custom_field_csv =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|> File.read!()
{:ok,
conn: conn,
admin_user: admin_user,
valid_csv_content: valid_csv_content,
invalid_csv_content: invalid_csv_content,
unknown_custom_field_csv: unknown_custom_field_csv}
end
test "success rendering: valid CSV shows success count", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing to complete
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
# Verify success count is shown
html = render(view)
assert html =~ "Successfully inserted" or html =~ "inserted"
end
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
conn: conn,
invalid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "invalid_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed with errors)
assert has_element?(view, "[data-testid='import-results-panel']")
# Check that error list exists
assert has_element?(view, "[data-testid='import-error-list']")
html = render(view)
# Should show failure count
assert html =~ "Failed" or html =~ "failed"
# Should show error list with line numbers (from service, not recalculated)
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
end
test "warning rendering: CSV with unknown custom field shows warnings block", %{
conn: conn,
unknown_custom_field_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
csv_path =
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
File.write!(csv_path, csv_content)
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: "unknown_custom.csv",
content: csv_content,
size: byte_size(csv_content),
type: "text/csv"
}
])
|> render_upload("unknown_custom.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
html = render(view)
# Should show warnings block (if warnings were generated)
# Warnings are generated when unknown custom field columns are detected
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
# If warnings exist, they should contain the column name
if has_warnings do
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
html =~ "will be ignored"
end
# Import should complete (either with or without warnings)
# Verified by import-results-panel existence above
end
test "A11y: file input has label", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check for label associated with file input
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
html =~ ~r/<label[^>]*>.*CSV File/i
end
test "A11y: status/progress container has aria-live", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for aria-live attribute in status area
assert html =~ ~r/aria-live=["']polite["']/i
end
test "A11y: links have descriptive text", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check that links have descriptive text (not just "click here")
# Template links should have text like "English Template" or "German Template"
assert html =~ "English Template" or html =~ "German Template" or
html =~ "English" or html =~ "German"
# Import page has link "Manage Member Data" and info text about "data field"
assert html =~ "Manage Member Data" or html =~ "data field" or html =~ "Data field"
end
end
describe "CSV Import - Step 5: Edge Cases" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Read CSV with BOM
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|> File.read!()
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "bom_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed successfully)
assert has_element?(view, "[data-testid='import-results-panel']")
html = render(view)
# Should succeed (BOM is stripped automatically)
assert html =~ "Successfully inserted" or html =~ "inserted"
# Should not show error about BOM
refute html =~ "BOM" or html =~ "encoding"
end
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|> File.read!()
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "empty_lines.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
html = render(view)
# Should show error with correct line number (line 4, not line 3)
# The error should be on the line with invalid email, which is after the empty line
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
# Should show error message
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
end
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Generate CSV with 1001 rows dynamically
header = "first_name;last_name;email;street;postal_code;city\n"
rows =
for i <- 1..1001 do
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
end
large_csv = header <> Enum.join(rows)
# Simulate file upload using helper function
upload_csv_file(view, large_csv, "too_many_rows.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
html = render(view)
# Should show user-friendly error about row limit
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
html =~ "Failed to prepare"
end
test "wrong file type (.txt): upload shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Create .txt file (not .csv)
txt_content = "This is not a CSV file\nJust some text\n"
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
File.write!(txt_path, txt_content)
# Try to upload .txt file
# Note: allow_upload is configured to accept only .csv, so this should fail
# In tests, we can't easily simulate file type rejection, but we can check
# that the UI shows appropriate help text
html = render(view)
# Should show CSV-only restriction in help text
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
end
test "file input has correct accept attribute for CSV only", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check that file input has accept attribute for CSV
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
end
end
end