prop/lib/microwaveprop_web/live/import_live.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign
  normalization, lat/lon validation, grid/latlon derivation across 6 schemas
- Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue
  for Oban workers, UUID casting to replace CastError rescues
- Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions),
  subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2
- Extract Propagation.ScoreStore (528 lines): separate file I/O and cache
  management from scoring logic, 13 defdelegate passthroughs
- Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into
  3 function component modules (csv_upload, adif_upload, preview)
- Update 16 LiveViews to use shared helpers
2026-08-06 18:06:50 -05:00

242 lines
7.6 KiB
Elixir

defmodule MicrowavepropWeb.ImportLive do
@moduledoc """
Shows live progress for an async CSV/ADIF contact import.
Mounts on `/imports/:id`, loads the `ImportRun`, and subscribes to the
`"csv_import:<id>"` PubSub topic so counters update as
`ContactImportWorker` chunks finish.
"""
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Radio.ImportRun
alias Microwaveprop.Repo
@impl true
def mount(%{"id" => id}, _session, socket) do
case load_run(id) do
nil -> not_found_redirect(socket)
%ImportRun{} = run -> mount_authorized(socket, run)
end
end
defp mount_authorized(socket, run) do
case authorized_to_view?(socket, run) do
:ok ->
subscribe(socket, "csv_import:#{run.id}")
{:ok,
socket
|> assign(:page_title, "Import ##{String.slice(run.id, 0, 8)}")
|> assign(:run, run)}
:not_found ->
not_found_redirect(socket)
end
end
defp not_found_redirect(socket) do
{:ok,
socket
|> put_flash(:error, "Import not found.")
|> push_navigate(to: ~p"/")}
end
defp authorized_to_view?(socket, run) do
viewer = socket.assigns[:current_scope] && socket.assigns.current_scope.user
cond do
viewer && viewer.is_admin -> :ok
viewer && viewer.email == run.submitter_email -> :ok
true -> :not_found
end
end
@impl true
def handle_info({:import_progress, _id, %ImportRun{} = updated}, socket) do
{:noreply, assign(socket, :run, updated)}
end
defp load_run(id) do
if valid_uuid?(id), do: Repo.get(ImportRun, id)
end
defp valid_uuid?(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, _} -> true
:error -> false
end
end
defp valid_uuid?(_), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Contact Import
<:subtitle>
Submitted by <span class="font-mono">{@run.submitter_email}</span>
&middot; {@run.total_rows} {pluralize(@run.total_rows, "row")}
</:subtitle>
<:actions>
<span id="import-status-badge" class={["badge badge-lg", status_badge_class(@run.status)]}>
{status_label(@run.status)}
</span>
</:actions>
</.header>
<div class="space-y-6">
<div>
<div class="flex items-center justify-between text-sm mb-1 tabular-nums">
<span>
{@run.processed_rows} / {@run.total_rows} processed
</span>
<span>{progress_percent(@run)}%</span>
</div>
<progress
id="import-progress-bar"
class={["progress w-full", progress_class(@run.status)]}
value={@run.processed_rows}
max={max(@run.total_rows, 1)}
></progress>
</div>
<div class="stats stats-vertical sm:stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">Imported</div>
<div id="import-imported-count" class="stat-value text-success tabular-nums">
{@run.imported_count}
</div>
<div class="stat-desc">New contacts inserted</div>
</div>
<div class="stat">
<div class="stat-title">Refined</div>
<div id="import-refined-count" class="stat-value text-info tabular-nums">
{@run.refined_count}
</div>
<div class="stat-desc">Existing contacts updated</div>
</div>
<div class="stat">
<div class="stat-title">Errors</div>
<div id="import-error-count" class="stat-value text-error tabular-nums">
{@run.error_count}
</div>
<div class="stat-desc">Rows that failed at insert</div>
</div>
</div>
<div
:if={@run.status == "complete"}
id="import-complete-panel"
class="alert alert-success"
phx-hook="ImportConfetti"
phx-update="ignore"
>
<.icon name="hero-check-circle" class="w-6 h-6" />
<div>
<div class="font-semibold">Import complete</div>
<div class="text-sm opacity-90">
Enrichment (weather, terrain, propagation) will continue in the background.
</div>
</div>
</div>
<div
:if={@run.status == "failed"}
id="import-failed-panel"
class="alert alert-error"
>
<.icon name="hero-exclamation-triangle" class="w-6 h-6" />
<span>Import failed before processing all rows.</span>
</div>
<details
:if={@run.error_count > 0}
id="import-errors-section"
class="rounded-box border border-base-300"
>
<summary class="cursor-pointer p-4 font-semibold">
{@run.error_count} {pluralize(@run.error_count, "row")} with errors
</summary>
<div class="overflow-x-auto p-4 pt-0">
<table class="table table-sm">
<thead>
<tr>
<th class="w-24">Row</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<tr :for={{row_num, messages} <- sorted_errors(@run.errors)}>
<td class="font-mono">Row {row_num}</td>
<td>
<div :for={msg <- messages}>{msg}</div>
</td>
</tr>
</tbody>
</table>
</div>
</details>
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
<.link navigate={~p"/submit"} class="btn btn-outline">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Upload another
</.link>
<.link
:if={@run.status == "complete"}
navigate={~p"/contacts"}
class="btn btn-primary"
>
View contacts <.icon name="hero-arrow-right" class="w-4 h-4" />
</.link>
</div>
</div>
</Layouts.app>
"""
end
defp status_label("pending"), do: "Pending"
defp status_label("running"), do: "Running"
defp status_label("complete"), do: "Complete"
defp status_label("failed"), do: "Failed"
defp status_label(other), do: to_string(other)
defp status_badge_class("pending"), do: "badge-neutral"
defp status_badge_class("running"), do: "badge-info"
defp status_badge_class("complete"), do: "badge-success"
defp status_badge_class("failed"), do: "badge-error"
defp status_badge_class(_), do: "badge-neutral"
defp progress_class("complete"), do: "progress-success"
defp progress_class("failed"), do: "progress-error"
defp progress_class("running"), do: "progress-info"
defp progress_class(_), do: "progress-primary"
defp progress_percent(%ImportRun{total_rows: 0}), do: 0
defp progress_percent(%ImportRun{total_rows: total, processed_rows: processed}) do
min(100, round(processed * 100 / total))
end
defp pluralize(1, word), do: word
defp pluralize(_, word), do: word <> "s"
# `errors` is a JSONB map of "row_num" (string) → [messages]. Sort by
# numeric row number for display; fall back to string order if a key
# can't be parsed as an integer.
defp sorted_errors(nil), do: []
defp sorted_errors(errors) when is_map(errors) do
errors
|> Enum.map(fn {row_num, messages} -> {row_num, List.wrap(messages)} end)
|> Enum.sort_by(fn {row_num, _} ->
case Integer.parse(to_string(row_num)) do
{n, _} -> n
:error -> :infinity
end
end)
end
end