feat(accounts): add home QTH fields to User

This commit is contained in:
Graham McIntire 2026-04-25 16:02:43 -05:00
parent cf7dec79cf
commit 5932a5e4f9
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 182 additions and 0 deletions

View file

@ -4,6 +4,8 @@ defmodule Microwaveprop.Accounts.User do
import Ecto.Changeset import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
@admin_email "graham@mcintire.me" @admin_email "graham@mcintire.me"
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@ -18,6 +20,11 @@ defmodule Microwaveprop.Accounts.User do
field :is_admin, :boolean, default: false field :is_admin, :boolean, default: false
field :authenticated_at, :utc_datetime, virtual: true field :authenticated_at, :utc_datetime, virtual: true
field :home_grid, :string
field :home_lat, :float
field :home_lon, :float
field :home_elevation_m, :integer
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -208,6 +215,114 @@ defmodule Microwaveprop.Accounts.User do
change(user, confirmed_at: now) change(user, confirmed_at: now)
end end
@doc """
Changeset for the rover-page "home QTH" fields.
Accepts either a Maidenhead grid or an explicit lat/lon. When only the
grid is given, lat/lon are derived from its centre; when only lat/lon
are given, the grid is left untouched. An empty `home_grid` paired
with explicit nil lat/lon clears all home fields (used by the "clear
home" affordance on /rover).
"""
@spec change_home_qth(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def change_home_qth(user, attrs) do
user
|> cast(attrs, [:home_grid, :home_lat, :home_lon, :home_elevation_m])
|> normalize_home_grid()
|> validate_home_grid_format()
|> derive_home_latlon_from_grid()
|> validate_home_latlon_range()
|> require_home_qth_pair()
end
defp normalize_home_grid(changeset) do
case get_change(changeset, :home_grid) do
nil ->
changeset
"" ->
put_change(changeset, :home_grid, nil)
grid when is_binary(grid) ->
put_change(changeset, :home_grid, normalize_grid(grid))
end
end
defp normalize_grid(grid) do
trimmed = String.trim(grid)
case String.length(trimmed) do
4 -> String.upcase(trimmed)
6 -> String.upcase(String.slice(trimmed, 0, 4)) <> String.downcase(String.slice(trimmed, 4, 2))
_ -> trimmed
end
end
defp validate_home_grid_format(changeset) do
case get_field(changeset, :home_grid) do
nil ->
changeset
grid ->
if Maidenhead.valid?(grid),
do: changeset,
else: add_error(changeset, :home_grid, "must be a 4 or 6 character Maidenhead locator")
end
end
defp derive_home_latlon_from_grid(changeset) do
cond do
not changeset.valid? ->
changeset
get_change(changeset, :home_lat) || get_change(changeset, :home_lon) ->
changeset
grid = get_change(changeset, :home_grid) ->
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} ->
changeset
|> put_change(:home_lat, lat)
|> put_change(:home_lon, lon)
:error ->
changeset
end
true ->
changeset
end
end
defp validate_home_latlon_range(changeset) do
changeset
|> validate_number(:home_lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(:home_lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
end
defp require_home_qth_pair(changeset) do
if home_qth_satisfied?(changeset),
do: changeset,
else: add_error(changeset, :home_grid, "must provide a grid or lat/lon")
end
defp home_qth_satisfied?(changeset) do
grid = get_field(changeset, :home_grid)
lat = get_field(changeset, :home_lat)
lon = get_field(changeset, :home_lon)
cond do
is_binary(grid) and grid != "" -> true
is_number(lat) and is_number(lon) -> true
is_nil(grid) and is_nil(lat) and is_nil(lon) -> any_home_change?(changeset)
true -> false
end
end
defp any_home_change?(changeset) do
Enum.any?([:home_grid, :home_lat, :home_lon], &Map.has_key?(changeset.changes, &1))
end
@doc """ @doc """
Verifies the password. Verifies the password.

View file

@ -0,0 +1,12 @@
defmodule Microwaveprop.Repo.Migrations.AddHomeQthToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :home_grid, :string
add :home_lat, :float
add :home_lon, :float
add :home_elevation_m, :integer
end
end
end

Binary file not shown.

View file

@ -442,4 +442,59 @@ defmodule Microwaveprop.Accounts.UserTest do
refute User.valid_password?(nil, "anything") refute User.valid_password?(nil, "anything")
end end
end end
describe "change_home_qth/2" do
test "derives lat/lon from a 6-char grid" do
changeset = User.change_home_qth(%User{}, %{"home_grid" => "EM13qc"})
assert changeset.valid?
lat = Ecto.Changeset.get_change(changeset, :home_lat)
lon = Ecto.Changeset.get_change(changeset, :home_lon)
assert_in_delta lat, 33.10, 0.05
assert_in_delta lon, -96.625, 0.05
assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc"
end
test "accepts explicit lat/lon without grid" do
changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.0, "home_lon" => -96.7})
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :home_lat) == 33.0
assert Ecto.Changeset.get_change(changeset, :home_lon) == -96.7
end
test "rejects invalid grids" do
changeset = User.change_home_qth(%User{}, %{"home_grid" => "ZZ99zz"})
refute changeset.valid?
assert errors_on(changeset)[:home_grid]
end
test "requires either grid or lat/lon" do
changeset = User.change_home_qth(%User{}, %{})
refute changeset.valid?
assert errors_on(changeset)[:home_grid]
end
test "rejects out-of-range lat/lon" do
changeset = User.change_home_qth(%User{}, %{"home_lat" => 100.0, "home_lon" => 0.0})
refute changeset.valid?
assert errors_on(changeset)[:home_lat]
end
test "uppercase Field + lowercase subsquare normalisation" do
changeset = User.change_home_qth(%User{}, %{"home_grid" => "em13QC"})
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc"
end
test "clears all home fields when home_grid is an empty string" do
user = %User{home_grid: "EM13qc", home_lat: 33.1, home_lon: -96.8, home_elevation_m: 200}
changeset =
User.change_home_qth(user, %{"home_grid" => "", "home_lat" => nil, "home_lon" => nil})
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :home_grid) == nil
assert Ecto.Changeset.get_change(changeset, :home_lat) == nil
assert Ecto.Changeset.get_change(changeset, :home_lon) == nil
end
end
end end