From 84d4fccb297e9ba0df3af6e066dcfb1317606b02 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 6 May 2026 14:49:32 -0500 Subject: [PATCH] feat: optionally attach a coverage to a device at the chosen site After picking a site in the coverage form, a new optional dropdown lets the user attach the coverage to one of the devices at that site. The dropdown reloads on every site change. If the site has no devices yet, a hint replaces it. Schema: nullable device_id FK on coverages (on_delete: nilify_all so deleting a device leaves the coverage standing). Filtered by both site_id and organization_id when loading the dropdown options. Show page lists the attached device by name (or IP) when set. --- CHANGELOG.txt | 11 +++++ lib/towerops/coverages/coverage.ex | 7 ++- lib/towerops_web/live/coverage_live/form.ex | 46 ++++++++++++++++++- .../live/coverage_live/form.html.heex | 15 ++++++ lib/towerops_web/live/coverage_live/show.ex | 2 +- .../live/coverage_live/show.html.heex | 7 +++ ...60506194539_add_device_id_to_coverages.exs | 14 ++++++ 7 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 priv/repo/migrations/20260506194539_add_device_id_to_coverages.exs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 2144b9bf..be0f0347 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,14 @@ +2026-05-06 +feat: optionally attach a coverage to a specific device at the chosen site + Schema: added nullable `device_id` FK to coverages (migration + 20260506194539_add_device_id_to_coverages, on_delete :nilify_all). + Form: after picking a site, a "Attach to device (optional)" dropdown + appears with that site's devices (filtered to the current org). The + dropdown reloads on every site change. If the site has no devices the + hint "This site has no devices yet" replaces the dropdown. + Show page: when set, the attached device's name (or IP) is displayed + in the parameters panel. + 2026-05-06 refactor: imperial coverage form + Deygout/curvature propagation, drop UI clutter Form (lib/towerops_web/live/coverage_live/form.html.heex): diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex index 4506f394..9f5269ae 100644 --- a/lib/towerops/coverages/coverage.ex +++ b/lib/towerops/coverages/coverage.ex @@ -16,6 +16,7 @@ defmodule Towerops.Coverages.Coverage do alias Ecto.Association.NotLoaded alias Towerops.Coverages.Antenna + alias Towerops.Devices.Device alias Towerops.Organizations.Organization alias Towerops.Sites.Site @@ -73,6 +74,7 @@ defmodule Towerops.Coverages.Coverage do belongs_to :site, Site belongs_to :organization, Organization + belongs_to :device, Device timestamps(type: :utc_datetime) end @@ -111,6 +113,8 @@ defmodule Towerops.Coverages.Coverage do site: NotLoaded.t() | Site.t() | nil, organization_id: Ecto.UUID.t() | nil, organization: NotLoaded.t() | Organization.t() | nil, + device_id: Ecto.UUID.t() | nil, + device: NotLoaded.t() | Device.t() | nil, inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } @@ -125,7 +129,7 @@ defmodule Towerops.Coverages.Coverage do tx_clearance_m foliage_tuning latitude_override longitude_override radius_m cell_size_m receiver_height_m rx_threshold_dbm - status progress_pct site_id + status progress_pct site_id device_id height_agl_ft height_above_rooftop_ft receiver_height_ft tx_clearance_ft radius_mi frequency_ghz )a @@ -189,6 +193,7 @@ defmodule Towerops.Coverages.Coverage do |> validate_inclusion(:status, @statuses) |> validate_pixel_budget() |> validate_antenna_exists() + |> foreign_key_constraint(:device_id) |> foreign_key_constraint(:site_id) |> foreign_key_constraint(:organization_id) |> unique_constraint(:name, diff --git a/lib/towerops_web/live/coverage_live/form.ex b/lib/towerops_web/live/coverage_live/form.ex index 639ba095..d307aee9 100644 --- a/lib/towerops_web/live/coverage_live/form.ex +++ b/lib/towerops_web/live/coverage_live/form.ex @@ -5,6 +5,7 @@ defmodule ToweropsWeb.CoverageLive.Form do alias Towerops.Coverages alias Towerops.Coverages.Antenna alias Towerops.Coverages.Coverage + alias Towerops.Devices alias Towerops.Sites # Defaults expressed in the user's preferred units (imperial / GHz). @@ -26,7 +27,8 @@ defmodule ToweropsWeb.CoverageLive.Form do |> assign(:organization, organization) |> assign(:sites, sites) |> assign(:antennas, antennas) - |> assign(:antenna_options, antenna_options(antennas))} + |> assign(:antenna_options, antenna_options(antennas)) + |> assign(:devices, [])} end @impl true @@ -66,6 +68,7 @@ defmodule ToweropsWeb.CoverageLive.Form do socket |> assign(:page_title, t("Edit Coverage")) |> assign(:coverage, coverage) + |> assign(:devices, devices_for_site(socket.assigns.organization.id, coverage.site_id)) |> assign_form(Coverages.change_coverage(coverage)) end @@ -76,6 +79,9 @@ defmodule ToweropsWeb.CoverageLive.Form do |> Coverages.change_coverage(attrs) |> Map.put(:action, :validate) + new_site_id = Map.get(attrs, "site_id") + socket = maybe_refresh_devices(socket, new_site_id) + {:noreply, assign_form(socket, changeset)} end @@ -170,6 +176,44 @@ defmodule ToweropsWeb.CoverageLive.Form do end) end + # Reload the devices dropdown when the user picks a different site. + # Clearing site_id clears the dropdown. + defp maybe_refresh_devices(socket, site_id) do + current_site_id = + case socket.assigns[:form] do + nil -> nil + form -> form[:site_id].value + end + + if to_string(site_id) == to_string(current_site_id) do + socket + else + assign(socket, :devices, devices_for_site(socket.assigns.organization.id, site_id)) + end + end + + defp devices_for_site(_org_id, site_id) when site_id in [nil, ""], do: [] + + defp devices_for_site(org_id, site_id) do + site_id + |> Devices.list_site_devices() + |> Enum.filter(&(&1.organization_id == org_id)) + end + + @doc false + def device_label(device) do + case device do + %{name: name, ip_address: ip} when is_binary(name) and name != "" -> + "#{name} (#{ip})" + + %{ip_address: ip} -> + "#{ip}" + + _ -> + "" + end + end + defp populate_imperial_virtuals(%Coverage{} = c) do %{ c diff --git a/lib/towerops_web/live/coverage_live/form.html.heex b/lib/towerops_web/live/coverage_live/form.html.heex index 266c8107..b918580c 100644 --- a/lib/towerops_web/live/coverage_live/form.html.heex +++ b/lib/towerops_web/live/coverage_live/form.html.heex @@ -57,6 +57,21 @@ required /> + <.input + :if={@devices != []} + field={@form[:device_id]} + type="select" + label={t("Attach to device (optional)")} + prompt={t("No specific device")} + options={Enum.map(@devices, &{device_label(&1), &1.id})} + /> +

+ {t("This site has no devices yet — coverage will not be attached to a specific device.")} +

+ <.input field={@form[:antenna_slug]} type="select" diff --git a/lib/towerops_web/live/coverage_live/show.ex b/lib/towerops_web/live/coverage_live/show.ex index da8027cd..2257099e 100644 --- a/lib/towerops_web/live/coverage_live/show.ex +++ b/lib/towerops_web/live/coverage_live/show.ex @@ -9,7 +9,7 @@ defmodule ToweropsWeb.CoverageLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do organization = socket.assigns.current_scope.organization - coverage = Coverages.get_coverage!(organization.id, id) + coverage = organization.id |> Coverages.get_coverage!(id) |> Towerops.Repo.preload(:device) antenna = Antenna.get(coverage.antenna_slug) eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0) diff --git a/lib/towerops_web/live/coverage_live/show.html.heex b/lib/towerops_web/live/coverage_live/show.html.heex index 205497d5..35d3270b 100644 --- a/lib/towerops_web/live/coverage_live/show.html.heex +++ b/lib/towerops_web/live/coverage_live/show.html.heex @@ -197,6 +197,13 @@ label={t("Antenna")} value={(@antenna && @antenna.model) || @coverage.antenna_slug} /> + <.param_row + :if={ + @coverage.device_id and Ecto.assoc_loaded?(@coverage.device) and @coverage.device + } + label={t("Device")} + value={@coverage.device.name || @coverage.device.ip_address} + /> <.param_row :if={@antenna} label={t("Manufacturer")} value={@antenna.manufacturer} /> <.param_row :if={@antenna} label={t("Antenna gain")} value={"#{@antenna.gain_dbi} dBi"} /> <.param_row label={t("Frequency")} value={format_ghz(@coverage.frequency_mhz)} /> diff --git a/priv/repo/migrations/20260506194539_add_device_id_to_coverages.exs b/priv/repo/migrations/20260506194539_add_device_id_to_coverages.exs new file mode 100644 index 00000000..f16c026d --- /dev/null +++ b/priv/repo/migrations/20260506194539_add_device_id_to_coverages.exs @@ -0,0 +1,14 @@ +defmodule Towerops.Repo.Migrations.AddDeviceIdToCoverages do + use Ecto.Migration + + def change do + alter table(:coverages) do + # Optional FK to a device at the parent site. The coverage represents + # the radio mounted on this device's tower. nil means "no specific + # device" (e.g. a planning study). + add :device_id, references(:devices, type: :binary_id, on_delete: :nilify_all) + end + + create index(:coverages, [:device_id]) + end +end