prop/lib/microwaveprop/rover_planning/mission.ex
Graham McIntire d31e783776
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m35s
fix: resolve 18 bugs across LiveViews, schemas, tests, and logic
- MonitorLive.Show: safe nil-guard on current_scope for anonymous access
- Admin.MonitorLive.Index: add phx-update=stream to enable stream ops
- ImportLive: require owner/admin authorization, not_found redirect
- MapLive: store timer refs in assigns, cancel before reschedule
- 10 schemas: add missing foreign_key_constraint on belongs_to
- Soundings: preload :station to eliminate N+1 in path analysis
- PathAnalysis: defensive preload of :station on soundings
- GridTaskEnqueuer: wrap reclaim_stale_running in Repo.transaction()
- HrdpsClient: replace String.to_atom with compile-time atom literals
- Contacts: fix extract_latlon false return for lon=0.0
- Tests: remove duplicate Mox.defmock, unblock swallowed task exits,
  bump refute_receive timeouts from 50ms to 200ms
2026-07-29 07:46:54 -05:00

139 lines
4.7 KiB
Elixir

defmodule Microwaveprop.RoverPlanning.Mission do
@moduledoc """
A planned rover mission. Owned by a user but globally visible. Holds
the band + height parameters that drive path-profile computation, the
list of stationary endpoints (`stations`), and the matrix of computed
paths from each rover-location to each station (`paths`).
`only_known_good` toggles whether the rover endpoint set is the
`:good` rover-locations (default) or every rover-location.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.RoverPlanning.Station
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_missions" do
field :name, :string
field :band_mhz, :integer, default: 10_000
field :bands_mhz, {:array, :integer}, default: []
field :only_known_good, :boolean, default: true
field :rover_height_ft, :float, default: 8.0
field :station_height_ft, :float, default: 30.0
field :notes, :string
belongs_to :user, User
has_many :stations, Station, foreign_key: :mission_id, on_replace: :delete
has_many :paths, Path, foreign_key: :mission_id
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@castable_fields [
:name,
:band_mhz,
:bands_mhz,
:only_known_good,
:rover_height_ft,
:station_height_ft,
:notes
]
@doc """
Returns the bands the mission's path matrix should iterate over.
Prefers `bands_mhz` (canonical, possibly empty) and falls back to
`[band_mhz]` for legacy rows. Always returns a non-empty list when
the mission is valid.
"""
@spec bands(t()) :: [integer()]
def bands(%__MODULE__{bands_mhz: list}) when is_list(list) and list != [], do: Enum.uniq(list)
def bands(%__MODULE__{band_mhz: primary}) when is_integer(primary), do: [primary]
def bands(_), do: []
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(mission, attrs) do
mission
|> cast(prepare_bands_attr(attrs), @castable_fields)
|> normalize_bands()
|> cast_assoc(:stations, with: &Station.changeset/2, required: true)
|> validate_required([:name, :band_mhz])
|> validate_length(:name, min: 1, max: 100)
|> validate_number(:band_mhz, greater_than: 0)
|> validate_bands_mhz()
|> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0)
|> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0)
|> validate_length(:notes, max: 4000)
|> validate_at_least_one_station()
|> foreign_key_constraint(:user_id)
end
# The multi-checkbox form sends a hidden empty-string sentinel so
# the key always exists even when nothing is checked. Drop those
# sentinels here so Ecto's `{:array, :integer}` cast doesn't choke.
defp prepare_bands_attr(%{"bands_mhz" => list} = attrs) when is_list(list) do
cleaned =
list
|> Enum.reject(fn v -> v == "" or v == nil end)
|> Enum.uniq()
Map.put(attrs, "bands_mhz", cleaned)
end
defp prepare_bands_attr(attrs), do: attrs
# Treats `bands_mhz` as the canonical multi-band list. When the
# form submits it (multi-checkbox), use that. When only `band_mhz`
# is present (legacy single-band form path), seed bands_mhz from it.
# Also keeps `band_mhz` in sync as the "primary band" so older
# readers stay correct.
defp normalize_bands(changeset) do
bands = get_change(changeset, :bands_mhz) || get_field(changeset, :bands_mhz)
primary = get_change(changeset, :band_mhz) || get_field(changeset, :band_mhz)
cond do
is_list(bands) and bands != [] ->
cleaned = bands |> Enum.uniq() |> Enum.sort()
changeset
|> put_change(:bands_mhz, cleaned)
|> put_change(:band_mhz, hd(cleaned))
is_integer(primary) and primary > 0 ->
put_change(changeset, :bands_mhz, [primary])
true ->
changeset
end
end
defp validate_bands_mhz(changeset) do
case get_field(changeset, :bands_mhz) do
list when is_list(list) and list != [] ->
if Enum.all?(list, &(is_integer(&1) and &1 > 0)),
do: changeset,
else: add_error(changeset, :bands_mhz, "must be a list of positive integers")
_ ->
add_error(changeset, :bands_mhz, "must include at least one band")
end
end
defp validate_at_least_one_station(changeset) do
case fetch_field(changeset, :stations) do
{_source, list} when is_list(list) and list != [] ->
kept = Enum.reject(list, fn s -> match?(%Ecto.Changeset{action: :replace}, s) end)
if kept == [], do: add_error(changeset, :stations, "must include at least one station"), else: changeset
_ ->
add_error(changeset, :stations, "must include at least one station")
end
end
end