fat: adds csv import live view to settings
This commit is contained in:
parent
bf9e47b257
commit
092fd99d48
8 changed files with 1098 additions and 30 deletions
|
|
@ -7,6 +7,7 @@ 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)
|
||||
|
|
@ -14,6 +15,29 @@ 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: 10 MB
|
||||
- Maximum rows: 1,000 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.
|
||||
|
|
@ -22,17 +46,38 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
alias MvWeb.Authorization
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
# CSV Import configuration constants
|
||||
@max_file_size_bytes 10_485_760 # 10 MB
|
||||
@max_errors 50
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign_form()}
|
||||
socket =
|
||||
socket
|
||||
|> 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_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: @max_file_size_bytes,
|
||||
auto_upload: true
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -78,6 +123,205 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
id="custom-fields-component"
|
||||
/>
|
||||
</.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">
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{gettext(
|
||||
"Custom fields must be created in Mila before importing CSV files with custom field columns"
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
{gettext(
|
||||
"Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<.link
|
||||
navigate={~p"/custom_field_values"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{gettext("Manage Custom Fields")}
|
||||
</.link>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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 10 MB")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<.button
|
||||
type="submit"
|
||||
phx-click="start_import"
|
||||
phx-disable-with={gettext("Starting import...")}
|
||||
variant="primary"
|
||||
disabled={
|
||||
Enum.empty?(@uploads.csv_file.entries) or
|
||||
@uploads.csv_file.entries |> List.first() |> then(&(&1 && not &1.done?))
|
||||
}
|
||||
>
|
||||
{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">
|
||||
<%= if @import_status == :running do %>
|
||||
<p class="text-sm">
|
||||
{gettext("Processing chunk %{current} of %{total}...",
|
||||
current: @import_progress.current_chunk,
|
||||
total: @import_progress.total_chunks
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_status == :done do %>
|
||||
<section class="space-y-4">
|
||||
<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 50 entries")}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if length(@import_progress.errors) > 0 do %>
|
||||
<div>
|
||||
<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
|
||||
|
|
@ -110,6 +354,100 @@ 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
|
||||
# Server-side admin check
|
||||
if Authorization.can?(socket.assigns[:current_user], :create, Mv.Membership.Member) do
|
||||
# Check if upload is completed
|
||||
upload_entries = socket.assigns.uploads.csv_file.entries
|
||||
|
||||
if Enum.empty?(upload_entries) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Please select a CSV file to import.")
|
||||
)}
|
||||
else
|
||||
entry = List.first(upload_entries)
|
||||
|
||||
if entry.done? do
|
||||
with {:ok, content} <- consume_and_read_csv(socket) do
|
||||
case MemberCSV.prepare(content) do
|
||||
{:ok, import_state} ->
|
||||
total_chunks = length(import_state.chunks)
|
||||
|
||||
progress = %{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: import_state.warnings || [],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: total_chunks
|
||||
}
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, progress)
|
||||
|> assign(:import_status, :running)
|
||||
|
||||
send(self(), {:process_chunk, 0})
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _error} = error_result ->
|
||||
error_result
|
||||
end
|
||||
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 =
|
||||
case error do
|
||||
%{message: msg} when is_binary(msg) -> msg
|
||||
%{errors: errors} when is_list(errors) -> inspect(errors)
|
||||
other -> inspect(other)
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to prepare CSV import: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Please wait for the file upload to complete before starting the import.")
|
||||
)}
|
||||
end
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Only administrators can import members from CSV files.")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
|
|
@ -180,6 +518,86 @@ 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
|
||||
process_chunk_and_schedule_next(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
|
||||
|
||||
# Processes a chunk and schedules the next one
|
||||
defp process_chunk_and_schedule_next(socket, import_state, progress, idx) do
|
||||
chunk = Enum.at(import_state.chunks, idx)
|
||||
|
||||
# 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: socket.assigns[:current_user]
|
||||
]
|
||||
|
||||
case MemberCSV.process_chunk(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts
|
||||
) do
|
||||
{:ok, chunk_result} ->
|
||||
# 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}
|
||||
|
||||
{:error, reason} ->
|
||||
# Chunk processing failed - mark as error
|
||||
handle_chunk_error(socket, :processing_failed, idx, reason)
|
||||
end
|
||||
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(
|
||||
|
|
@ -192,4 +610,72 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp consume_and_read_csv(socket) do
|
||||
result =
|
||||
case consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
|
||||
File.read!(path)
|
||||
end) do
|
||||
[{_name, content}] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
[] ->
|
||||
{:error, gettext("No file was uploaded")}
|
||||
|
||||
[{_name, {:ok, content}}] when is_binary(content) ->
|
||||
# Handle case where callback returns {:ok, content}
|
||||
{:ok, content}
|
||||
|
||||
[content] when is_binary(content) ->
|
||||
# Handle case where consume_uploaded_entries returns a list with the content directly
|
||||
{:ok, content}
|
||||
|
||||
_other ->
|
||||
{:error, gettext("Failed to read uploaded file")}
|
||||
end
|
||||
|
||||
result
|
||||
rescue
|
||||
e in File.Error ->
|
||||
{:error, gettext("Failed to read file: %{reason}", reason: Exception.message(e))}
|
||||
end
|
||||
|
||||
defp merge_progress(progress, chunk_result, current_chunk_idx) do
|
||||
# Merge errors with cap of 50 overall
|
||||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, 50)
|
||||
errors_truncated? = length(all_errors) > 50
|
||||
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue