Add /submit route for user QSO submission

Maidenhead grid module converts grid squares to lat/lon coordinates.
Submission form validates grids, bands, modes, and email, computes
positions and distance, then triggers the weather/HRRR/terrain
processing pipeline via Oban.
This commit is contained in:
Graham McIntire 2026-03-29 16:46:35 -05:00
parent d67ced2a0c
commit 7d0978f7d5
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
12 changed files with 587 additions and 0 deletions

View file

@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do
import Ecto.Query import Ecto.Query
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.Qso alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo alias Microwaveprop.Repo
@ -124,4 +125,31 @@ defmodule Microwaveprop.Radio do
def get_qso!(id) do def get_qso!(id) do
Repo.get!(Qso, id) Repo.get!(Qso, id)
end end
def change_qso(qso, attrs \\ %{}) do
Qso.submission_changeset(qso, attrs)
end
def create_qso(attrs) do
changeset = Qso.submission_changeset(%Qso{}, attrs)
if changeset.valid? do
grid1 = Ecto.Changeset.get_change(changeset, :grid1)
grid2 = Ecto.Changeset.get_change(changeset, :grid2)
{:ok, {lat1, lon1}} = Maidenhead.to_latlon(grid1)
{:ok, {lat2, lon2}} = Maidenhead.to_latlon(grid2)
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
changeset
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|> Ecto.Changeset.put_change(:distance_km, distance)
|> Ecto.Changeset.put_change(:user_submitted, true)
|> Repo.insert()
else
{:error, %{changeset | action: :insert}}
end
end
end end

View file

@ -0,0 +1,41 @@
defmodule Microwaveprop.Radio.Maidenhead do
@moduledoc false
@spec valid?(any()) :: boolean()
def valid?(nil), do: false
def valid?(grid) when is_binary(grid) do
String.match?(grid, ~r/^[A-Ra-r]{2}[0-9]{2}$/) or
String.match?(grid, ~r/^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}$/)
end
def valid?(_), do: false
@spec to_latlon(any()) :: {:ok, {float(), float()}} | :error
def to_latlon(nil), do: :error
def to_latlon(grid) when is_binary(grid) do
grid = String.upcase(grid)
if valid?(grid) do
<<f1, f2, s1, s2>> <> rest = grid
lon = (f1 - ?A) * 20 + (s1 - ?0) * 2 - 180
lat = (f2 - ?A) * 10 + (s2 - ?0) * 1 - 90
case rest do
<<ss1, ss2>> ->
lon = lon + (ss1 - ?A) * (5 / 60) + 5 / 120
lat = lat + (ss2 - ?A) * (2.5 / 60) + 2.5 / 120
{:ok, {lat, lon}}
"" ->
{:ok, {lat + 0.5, lon + 1.0}}
end
else
:error
end
end
def to_latlon(_), do: :error
end

View file

@ -4,6 +4,8 @@ defmodule Microwaveprop.Radio.Qso do
import Ecto.Changeset import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -21,6 +23,8 @@ defmodule Microwaveprop.Radio.Qso do
field :weather_queued, :boolean, default: false field :weather_queued, :boolean, default: false
field :hrrr_queued, :boolean, default: false field :hrrr_queued, :boolean, default: false
field :terrain_queued, :boolean, default: false field :terrain_queued, :boolean, default: false
field :user_submitted, :boolean, default: false
field :submitter_email, :string
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -33,4 +37,29 @@ defmodule Microwaveprop.Radio.Qso do
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields) |> validate_required(@required_fields)
end end
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
@allowed_modes ~w(CW SSB FM FT8 FT4)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
def submission_changeset(qso, attrs) do
qso
|> cast(attrs, @submission_fields)
|> validate_required(@submission_fields)
|> validate_grid(:grid1)
|> validate_grid(:grid2)
|> validate_inclusion(:mode, @allowed_modes)
|> validate_inclusion(:band, @allowed_bands)
|> validate_format(:submitter_email, ~r/@/)
end
defp validate_grid(changeset, field) do
validate_change(changeset, field, fn _, value ->
if Maidenhead.valid?(value) do
[]
else
[{field, "is not a valid Maidenhead grid square"}]
end
end)
end
end end

View file

@ -63,6 +63,11 @@ defmodule MicrowavepropWeb.QsoLive.Index do
<.header> <.header>
QSOs QSOs
<:subtitle>{@total_entries} contacts</:subtitle> <:subtitle>{@total_entries} contacts</:subtitle>
<:actions>
<.link navigate={~p"/submit"} class="btn btn-sm btn-primary">
<.icon name="hero-plus" class="w-4 h-4" /> Submit QSO
</.link>
</:actions>
</.header> </.header>
<.table <.table

View file

@ -162,6 +162,12 @@ defmodule MicrowavepropWeb.QsoLive.Show do
<:item title="Grid 2">{@qso.grid2 || ""}</:item> <:item title="Grid 2">{@qso.grid2 || ""}</:item>
<:item title="Distance">{@qso.distance_km || ""} km</:item> <:item title="Distance">{@qso.distance_km || ""} km</:item>
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item> <:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
<:item :if={@qso.user_submitted} title="Source">
<span class="badge badge-info badge-sm">User Submitted</span>
</:item>
<:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter">
{@qso.submitter_email}
</:item>
</.list> </.list>
<div class="divider" /> <div class="divider" />

View file

@ -0,0 +1,125 @@
defmodule MicrowavepropWeb.SubmitLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
@band_options [
{"1296 MHz", "1296"},
{"2304 MHz", "2304"},
{"3456 MHz", "3456"},
{"5760 MHz", "5760"},
{"10 GHz", "10000"},
{"24 GHz", "24000"},
{"47 GHz", "47000"},
{"76 GHz", "75000"}
]
@mode_options ~w(CW SSB FM FT8 FT4)
@impl true
def mount(_params, _session, socket) do
changeset = Radio.change_qso(%Qso{})
{:ok,
assign(socket,
page_title: "Submit QSO",
form: to_form(changeset),
band_options: @band_options,
mode_options: @mode_options
)}
end
@impl true
def handle_event("validate", %{"qso" => qso_params}, socket) do
changeset =
%Qso{}
|> Radio.change_qso(qso_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("save", %{"qso" => qso_params}, socket) do
case Radio.create_qso(qso_params) do
{:ok, qso} ->
Oban.insert(QsoWeatherEnqueueWorker.new(%{}))
{:noreply,
socket
|> put_flash(:info, "QSO submitted successfully!")
|> push_navigate(to: ~p"/qsos/#{qso.id}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Submit QSO
<:subtitle>Submit a microwave QSO for propagation analysis</:subtitle>
</.header>
<.form for={@form} id="qso-form" phx-change="validate" phx-submit="save" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" />
<.input
field={@form[:grid1]}
type="text"
label="Grid 1"
placeholder="EM12kp"
phx-debounce="blur"
/>
<.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" />
<.input
field={@form[:grid2]}
type="text"
label="Grid 2"
placeholder="EM00cd"
phx-debounce="blur"
/>
</div>
<p class="text-sm text-base-content/60 -mt-2">
Be as specific as possible with grid squares (6 characters preferred, e.g. EM12kp).
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input
field={@form[:band]}
type="select"
label="Band"
prompt="Select band"
options={@band_options}
/>
<.input
field={@form[:mode]}
type="select"
label="Mode"
prompt="Select mode"
options={@mode_options}
/>
<.input field={@form[:qso_timestamp]} type="datetime-local" label="Timestamp (UTC)" />
</div>
<.input
field={@form[:submitter_email]}
type="email"
label="Your Email"
placeholder="you@example.com"
/>
<div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn-primary">Submit QSO</.button>
</div>
</.form>
</Layouts.app>
"""
end
end

View file

@ -19,6 +19,7 @@ defmodule MicrowavepropWeb.Router do
get "/", PageController, :home get "/", PageController, :home
live "/submit", SubmitLive
live "/qsos", QsoLive.Index live "/qsos", QsoLive.Index
live "/qsos/:id", QsoLive.Show live "/qsos/:id", QsoLive.Show
end end

View file

@ -0,0 +1,10 @@
defmodule Microwaveprop.Repo.Migrations.AddSubmissionFieldsToQsos do
use Ecto.Migration
def change do
alter table(:qsos) do
add :user_submitted, :boolean, default: false, null: false
add :submitter_email, :string
end
end
end

View file

@ -0,0 +1,94 @@
defmodule Microwaveprop.Radio.MaidenheadTest do
use ExUnit.Case, async: true
alias Microwaveprop.Radio.Maidenhead
describe "valid?/1" do
test "accepts valid 4-char grid" do
assert Maidenhead.valid?("EM12")
end
test "accepts valid 6-char grid" do
assert Maidenhead.valid?("EM12ab")
end
test "is case-insensitive" do
assert Maidenhead.valid?("em12")
assert Maidenhead.valid?("EM12AB")
assert Maidenhead.valid?("em12ab")
end
test "rejects field chars outside A-R" do
refute Maidenhead.valid?("SZ12")
end
test "rejects subsquare chars outside a-x" do
refute Maidenhead.valid?("EM12yz")
end
test "rejects wrong length" do
refute Maidenhead.valid?("EM1")
refute Maidenhead.valid?("EM123")
refute Maidenhead.valid?("EM12abc")
end
test "rejects non-digit in square positions" do
refute Maidenhead.valid?("EMAB")
end
test "rejects empty string" do
refute Maidenhead.valid?("")
end
test "rejects nil" do
refute Maidenhead.valid?(nil)
end
end
describe "to_latlon/1" do
test "returns center of 4-char grid FN31" do
# FN31: F=5, N=13 -> lon = 5*20 - 180 + 3*2 + 1 = -73, lat = 13*10 - 90 + 1*1 + 0.5 = 41.5
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31")
assert_in_delta lat, 41.5, 0.01
assert_in_delta lon, -73.0, 0.01
end
test "returns center of 6-char grid FN31pr" do
# FN31pr: field F=5,N=13; square 3,1; subsquare p=15,r=17
# lon = 5*20 - 180 + 3*2 + 15*(5/60) + (5/120) = -100 + 6 + 1.25 + 0.0417 = -72.7083
# lat = 13*10 - 90 + 1*1 + 17*(2.5/60) + (2.5/120) = 40 + 1 + 0.7083 + 0.02083 = 41.7292
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31pr")
assert_in_delta lat, 41.7292, 0.01
assert_in_delta lon, -72.7083, 0.01
end
test "is case-insensitive" do
assert {:ok, {lat1, lon1}} = Maidenhead.to_latlon("FN31")
assert {:ok, {lat2, lon2}} = Maidenhead.to_latlon("fn31")
assert lat1 == lat2
assert lon1 == lon2
end
test "returns :error for invalid grid" do
assert :error = Maidenhead.to_latlon("ZZ99")
end
test "returns :error for nil" do
assert :error = Maidenhead.to_latlon(nil)
end
test "AA00 returns southwest corner center" do
# lon = 0*20 - 180 + 0*2 + 1 = -179, lat = 0*10 - 90 + 0*1 + 0.5 = -89.5
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("AA00")
assert_in_delta lat, -89.5, 0.01
assert_in_delta lon, -179.0, 0.01
end
test "RR99 returns northeast corner center" do
# lon = 17*20 - 180 + 9*2 + 1 = 179, lat = 17*10 - 90 + 9*1 + 0.5 = 89.5
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("RR99")
assert_in_delta lat, 89.5, 0.01
assert_in_delta lon, 179.0, 0.01
end
end
end

View file

@ -0,0 +1,95 @@
defmodule Microwaveprop.Radio.QsoSubmissionTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
@valid_attrs %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: "1296",
grid1: "EM12",
grid2: "EM00",
submitter_email: "test@example.com"
}
describe "submission_changeset/2" do
test "valid attrs produce a valid changeset" do
changeset = Qso.submission_changeset(%Qso{}, @valid_attrs)
assert changeset.valid?
end
test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do
changeset = Qso.submission_changeset(%Qso{}, %{})
errors = errors_on(changeset)
assert errors[:station1]
assert errors[:station2]
assert errors[:qso_timestamp]
assert errors[:mode]
assert errors[:band]
assert errors[:grid1]
assert errors[:grid2]
assert errors[:submitter_email]
end
test "rejects invalid grid1" do
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1
end
test "rejects invalid grid2" do
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2
end
test "accepts valid 6-char grids" do
attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"}
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert changeset.valid?
end
test "rejects invalid mode" do
attrs = Map.put(@valid_attrs, :mode, "RTTY")
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert errors_on(changeset).mode
end
test "accepts valid modes" do
for mode <- ~w(CW SSB FM FT8 FT4) do
attrs = Map.put(@valid_attrs, :mode, mode)
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert changeset.valid?, "Expected mode #{mode} to be valid"
end
end
test "rejects invalid band" do
attrs = Map.put(@valid_attrs, :band, "144")
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert errors_on(changeset).band
end
test "accepts valid microwave bands" do
for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do
attrs = Map.put(@valid_attrs, :band, band)
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert changeset.valid?, "Expected band #{band} to be valid"
end
end
test "rejects email without @" do
attrs = Map.put(@valid_attrs, :submitter_email, "notanemail")
changeset = Qso.submission_changeset(%Qso{}, attrs)
assert errors_on(changeset).submitter_email
end
test "does not cast user_submitted" do
attrs = Map.put(@valid_attrs, :user_submitted, true)
changeset = Qso.submission_changeset(%Qso{}, attrs)
refute Ecto.Changeset.get_change(changeset, :user_submitted)
end
end
end

View file

@ -345,4 +345,65 @@ defmodule Microwaveprop.RadioTest do
assert Radio.mark_terrain_queued!([]) == {0, nil} assert Radio.mark_terrain_queued!([]) == {0, nil}
end end
end end
describe "change_qso/2" do
test "returns a submission changeset" do
changeset = Radio.change_qso(%Qso{})
assert %Ecto.Changeset{} = changeset
end
test "accepts attrs" do
changeset = Radio.change_qso(%Qso{}, %{station1: "W5XD"})
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
end
end
describe "create_qso/1" do
@valid_submission %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: "1296",
grid1: "EM12",
grid2: "EM00",
submitter_email: "test@example.com"
}
test "creates QSO with computed positions and distance" do
assert {:ok, qso} = Radio.create_qso(@valid_submission)
assert qso.station1 == "W5XD"
assert qso.user_submitted == true
assert qso.pos1["lat"]
assert qso.pos1["lon"]
assert qso.pos2["lat"]
assert qso.pos2["lon"]
assert qso.distance_km
end
test "computes correct position from grid" do
assert {:ok, qso} = Radio.create_qso(@valid_submission)
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
assert_in_delta qso.pos1["lat"], 32.5, 0.1
assert_in_delta qso.pos1["lon"], -97.0, 0.1
end
test "sets user_submitted to true" do
assert {:ok, qso} = Radio.create_qso(@valid_submission)
assert qso.user_submitted == true
end
test "returns error changeset for invalid data" do
assert {:error, %Ecto.Changeset{}} = Radio.create_qso(%{})
end
test "returns error changeset for invalid grid" do
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
assert {:error, changeset} = Radio.create_qso(attrs)
assert errors_on(changeset).grid1
end
end
end end

View file

@ -0,0 +1,92 @@
defmodule MicrowavepropWeb.SubmitLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.IemClient
setup do
Req.Test.stub(IemClient, fn conn ->
case conn.request_path do
"/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
_ -> Req.Test.json(conn, %{"profiles" => []})
end
end)
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
describe "mount" do
test "renders the submission form", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/submit")
assert html =~ "Submit QSO"
assert html =~ "Station 1"
assert html =~ "Station 2"
assert html =~ "Grid 1"
assert html =~ "Grid 2"
assert html =~ "Band"
assert html =~ "Mode"
end
end
describe "validate" do
test "shows validation errors on change", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
html =
lv
|> form("#qso-form", qso: %{station1: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
end
describe "save" do
test "creates QSO and redirects on valid submit", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> form("#qso-form",
qso: %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: "2026-03-28T18:00",
mode: "CW",
band: "1296",
grid1: "EM12",
grid2: "EM00",
submitter_email: "test@example.com"
}
)
|> render_submit()
qso = Repo.one!(Qso)
assert qso.station1 == "W5XD"
assert qso.user_submitted == true
assert qso.pos1["lat"]
assert_redirect(lv, ~p"/qsos/#{qso.id}")
end
test "shows errors on invalid submit", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit")
html =
lv
|> form("#qso-form", qso: %{station1: ""})
|> render_submit()
assert html =~ "can&#39;t be blank"
end
end
end