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.
This commit is contained in:
Graham McIntire 2026-05-06 14:49:32 -05:00
parent 1b6a548f3a
commit 84d4fccb29
7 changed files with 99 additions and 3 deletions

View file

@ -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):

View file

@ -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,

View file

@ -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

View file

@ -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})}
/>
<p
:if={@form[:site_id].value not in [nil, ""] and @devices == []}
class="text-xs text-gray-500 dark:text-gray-400"
>
{t("This site has no devices yet — coverage will not be attached to a specific device.")}
</p>
<.input
field={@form[:antenna_slug]}
type="select"

View file

@ -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)

View file

@ -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)} />

View file

@ -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