- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign normalization, lat/lon validation, grid/latlon derivation across 6 schemas - Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue for Oban workers, UUID casting to replace CastError rescues - Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions), subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2 - Extract Propagation.ScoreStore (528 lines): separate file I/O and cache management from scoring logic, 13 defdelegate passthroughs - Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into 3 function component modules (csv_upload, adif_upload, preview) - Update 16 LiveViews to use shared helpers
156 lines
5 KiB
Elixir
156 lines
5 KiB
Elixir
defmodule Microwaveprop.Radio.MaidenheadChangesetHelpers do
|
|
@moduledoc """
|
|
Shared changeset helpers for Maidenhead grid squares, callsigns, and
|
|
lat/lon coordinate validation. Used across multiple domain schemas
|
|
(Beacon, User, FixedStation, Contact, Location, BeaconMonitor).
|
|
"""
|
|
|
|
import Ecto.Changeset
|
|
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
|
|
@doc """
|
|
Trims whitespace and uppercases a callsign field if a change is present.
|
|
Returns the changeset unchanged when the field has no change or the
|
|
change is `nil`.
|
|
"""
|
|
@spec normalize_callsign(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
|
|
def normalize_callsign(changeset, field) do
|
|
update_change(changeset, field, fn
|
|
nil -> nil
|
|
val when is_binary(val) -> val |> String.trim() |> String.upcase()
|
|
val -> val
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Validates a Maidenhead grid field on the changeset.
|
|
|
|
## Options
|
|
|
|
* `:blank_error` — when `true` (default), a `nil` or empty-string value
|
|
adds a "can't be blank" error. When `false`, blank values pass through
|
|
silently.
|
|
* `:normalizer` — a function of one argument called to normalize the
|
|
grid when it passes validation. Defaults to `normalize_grid/1`.
|
|
"""
|
|
@spec validate_grid(Ecto.Changeset.t(), atom(), keyword()) :: Ecto.Changeset.t()
|
|
def validate_grid(changeset, field, opts \\ []) do
|
|
blank_error = Keyword.get(opts, :blank_error, true)
|
|
normalizer = Keyword.get(opts, :normalizer, &normalize_grid/1)
|
|
|
|
case get_field(changeset, field) do
|
|
nil ->
|
|
if blank_error do
|
|
add_error(changeset, field, "can't be blank")
|
|
else
|
|
changeset
|
|
end
|
|
|
|
"" ->
|
|
if blank_error do
|
|
add_error(changeset, field, "can't be blank")
|
|
else
|
|
changeset
|
|
end
|
|
|
|
value ->
|
|
if Maidenhead.valid?(value) do
|
|
update_change(changeset, field, normalizer)
|
|
else
|
|
add_error(changeset, field, "is not a valid Maidenhead grid")
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Normalizes a Maidenhead grid string to standard form.
|
|
|
|
* `nil` → `nil`
|
|
* Trims leading and trailing whitespace.
|
|
* For grids of 6 or more characters: uppercases the first 4 characters
|
|
(field + square) and downcases the rest (subsquares and beyond).
|
|
* For grids of fewer than 6 characters: uppercases the entire string.
|
|
"""
|
|
@spec normalize_grid(nil | String.t()) :: nil | String.t()
|
|
def normalize_grid(nil), do: nil
|
|
|
|
def normalize_grid(grid) when is_binary(grid) do
|
|
trimmed = String.trim(grid)
|
|
|
|
if String.length(trimmed) >= 6 do
|
|
String.upcase(String.slice(trimmed, 0, 4)) <> String.downcase(String.slice(trimmed, 4, String.length(trimmed) - 4))
|
|
else
|
|
String.upcase(trimmed)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Derives lat/lon coordinates from a Maidenhead grid field when lat/lon
|
|
are not already set. Only runs when the changeset is valid.
|
|
|
|
Uses the centre point of the grid square, rounded to 6 decimal places.
|
|
"""
|
|
@spec derive_latlon(
|
|
Ecto.Changeset.t(),
|
|
atom(),
|
|
atom(),
|
|
atom()
|
|
) :: Ecto.Changeset.t()
|
|
def derive_latlon(changeset, grid_field \\ :grid, lat_field \\ :lat, lon_field \\ :lon) do
|
|
grid = get_field(changeset, grid_field)
|
|
lat = get_field(changeset, lat_field)
|
|
lon = get_field(changeset, lon_field)
|
|
|
|
if changeset.valid? and is_binary(grid) and grid != "" and
|
|
(not is_number(lat) or not is_number(lon)) do
|
|
case Maidenhead.to_latlon(grid) do
|
|
{:ok, {derived_lat, derived_lon}} ->
|
|
changeset
|
|
|> put_change(lat_field, Float.round(derived_lat * 1.0, 6))
|
|
|> put_change(lon_field, Float.round(derived_lon * 1.0, 6))
|
|
|
|
:error ->
|
|
changeset
|
|
end
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Derives a Maidenhead grid from lat/lon coordinates when the grid field
|
|
is blank and lat/lon are numbers.
|
|
|
|
The `precision` must be an even number between 4 and 10 (default 6).
|
|
"""
|
|
@spec derive_grid(
|
|
Ecto.Changeset.t(),
|
|
atom(),
|
|
atom(),
|
|
atom(),
|
|
pos_integer()
|
|
) :: Ecto.Changeset.t()
|
|
def derive_grid(changeset, grid_field \\ :grid, lat_field \\ :lat, lon_field \\ :lon, precision \\ 6) do
|
|
grid = get_field(changeset, grid_field)
|
|
lat = get_field(changeset, lat_field)
|
|
lon = get_field(changeset, lon_field)
|
|
|
|
if grid in [nil, ""] and is_number(lat) and is_number(lon) do
|
|
put_change(changeset, grid_field, Maidenhead.from_latlon(lat * 1.0, lon * 1.0, precision))
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Validates that latitude is between -90 and 90 and longitude is between
|
|
-180 and 180.
|
|
"""
|
|
@spec validate_latlon(Ecto.Changeset.t(), atom(), atom()) :: Ecto.Changeset.t()
|
|
def validate_latlon(changeset, lat_field \\ :lat, lon_field \\ :lon) do
|
|
changeset
|
|
|> validate_number(lat_field, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
|
|> validate_number(lon_field, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
|
end
|
|
end
|