towerops/lib/towerops_web/live/coverage_live/form.ex
Graham McIntire 5a9381f91a feat: add /coverage RF prediction feature scaffold
End-to-end CRUD scaffold for per-site, per-antenna RF coverage
prediction. Schema, context, antenna registry, Oban worker stub, and
three LiveViews are wired up; the actual ITM + LIDAR + buildings
compute pipeline lands in a follow-up.

- Migration + Coverage schema with full validation (RF params, pixel-
  budget cap, antenna_slug existence). organization_id excluded from
  cast (programmatic-only).
- Coverages context: org-scoped CRUD, validate_site_in_organization
  rejects cross-tenant site_id refs at insert/update, queue_compute
  with PubSub broadcast on per-coverage and per-org topics.
- MSI Planet .ant parser + persistent_term registry loaded at boot
  from priv/antennas/. Two synthetic .ant fixtures (omni + 90deg
  sector) bundled.
- CoverageWorker on new :coverage Oban queue (concurrency 2). Job
  args carry organization_id; worker refuses to run on org mismatch.
  Stub flips status to "failed" with "compute not yet implemented"
  so the UI flow is exercisable.
- LiveViews: index (org-wide, live status updates), form (antenna
  picker grouped by manufacturer), show (status banners, params
  panel, map placeholder). Sidebar nav link added.
- Routes /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit
  under :require_authenticated_user_with_default_org.
- 41 new tests covering schema validation, .ant parsing, context
  CRUD with cross-org guards, Oban job enqueue.
2026-05-06 13:44:14 -05:00

106 lines
3 KiB
Elixir

defmodule ToweropsWeb.CoverageLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
alias Towerops.Coverages.Coverage
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
antennas = Antenna.list()
{:ok,
socket
|> assign(:organization, organization)
|> assign(:sites, sites)
|> assign(:antennas, antennas)
|> assign(:antenna_options, antenna_options(antennas))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
coverage = %Coverage{
organization_id: socket.assigns.organization.id,
downtilt_deg: 0.0,
receiver_height_m: 3.0,
rx_threshold_dbm: -90.0,
cell_size_m: 10,
radius_m: 5_000
}
socket
|> assign(:page_title, t("New Coverage"))
|> assign(:coverage, coverage)
|> assign(:form, to_form(Coverages.change_coverage(coverage)))
end
defp apply_action(socket, :edit, %{"id" => id}) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
socket
|> assign(:page_title, t("Edit Coverage"))
|> assign(:coverage, coverage)
|> assign(:form, to_form(Coverages.change_coverage(coverage)))
end
@impl true
def handle_event("validate", %{"coverage" => attrs}, socket) do
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("save", %{"coverage" => attrs}, socket) do
save(socket, socket.assigns.live_action, attrs)
end
defp save(socket, :new, attrs) do
case Coverages.create_coverage(socket.assigns.organization.id, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage created"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save(socket, :edit, attrs) do
case Coverages.update_coverage(socket.assigns.coverage, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage updated"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp antenna_options(antennas) do
antennas
|> Enum.group_by(& &1.manufacturer)
|> Enum.sort_by(fn {mfr, _} -> mfr || "" end)
|> Enum.map(fn {mfr, ants} ->
{mfr || t("Other"),
Enum.map(ants, fn ant ->
{"#{ant.model} (#{ant.gain_dbi} dBi)", ant.slug}
end)}
end)
end
end