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:
parent
1b6a548f3a
commit
84d4fccb29
7 changed files with 99 additions and 3 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue