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