feat(accounts): auto-prefill home QTH from QRZ on register + backfill on boot

This commit is contained in:
Graham McIntire 2026-04-25 17:03:01 -05:00
parent b1fe077863
commit 9796f06ba2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 197 additions and 1 deletions

View file

@ -48,6 +48,11 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
# Run Oban jobs inline during tests
config :microwaveprop, Oban, testing: :inline
# Skip the registration-time QRZ lookup in tests — it needs a Mox stub
# that isn't set up across the test suite, and the worker is exercised
# directly in its own dedicated test.
config :microwaveprop, :enable_home_qth_lookup, false
# MetricsLogSuppressionTest hits GET /metrics and asserts no "Sent 200"
# log line; that path needs PromEx alive to return a 200. We can't
# blanket-disable PromEx. Instead, toggle off the Oban-queue poller

View file

@ -9,8 +9,8 @@ defmodule Microwaveprop.Accounts do
alias Microwaveprop.Accounts.UserNotifier
alias Microwaveprop.Accounts.UserToken
alias Microwaveprop.Repo
## Database getters
alias Microwaveprop.Workers.UserHomeQthLookupWorker
@doc """
Gets a user by email.
@ -132,6 +132,47 @@ defmodule Microwaveprop.Accounts do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
|> maybe_enqueue_home_qth_lookup()
end
defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
if home_qth_lookup_enabled?() do
worker = UserHomeQthLookupWorker
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
_ = %{user_id: id} |> worker.new() |> Oban.insert()
end
end
{:ok, user}
end
defp maybe_enqueue_home_qth_lookup(other), do: other
defp home_qth_lookup_enabled? do
Application.get_env(:microwaveprop, :enable_home_qth_lookup, true)
end
@doc """
Enqueue a `UserHomeQthLookupWorker` job for every user whose
`home_grid` is still nil. Safe to call repeatedly the worker
no-ops when the user has set their QTH manually.
"""
@spec backfill_missing_home_qth() :: :ok
def backfill_missing_home_qth do
worker = UserHomeQthLookupWorker
if home_qth_lookup_enabled?() and Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
User
|> where([u], is_nil(u.home_grid))
|> select([u], u.id)
|> Repo.all()
|> Enum.each(fn id ->
_ = %{user_id: id} |> worker.new() |> Oban.insert()
end)
end
:ok
end
@doc """

View file

@ -60,6 +60,13 @@ defmodule Microwaveprop.Application do
# lifetime of the pod.
{:ok, _pid} = Task.start(fn -> Microwaveprop.Weather.warm_grid_cache_from_latest_profile() end)
# One-shot backfill: enqueue a QRZ-lookup job for every user with a
# missing home QTH. Idempotent — the worker no-ops if home_grid is
# already set, so re-running on every boot is safe (and useful: any
# users registered while the worker module was unavailable get
# picked up the next time the pod restarts).
{:ok, _pid} = Task.start(fn -> Microwaveprop.Accounts.backfill_missing_home_qth() end)
# Load ML model in dev/test only (Nx/Axon not compiled for prod)
if Application.get_env(:microwaveprop, :load_ml_model, false) do
Microwaveprop.Propagation.load_ml_model()

View file

@ -0,0 +1,70 @@
defmodule Microwaveprop.Workers.UserHomeQthLookupWorker do
@moduledoc """
Looks up a user's callsign via QRZ (with Google Maps fallback) and
populates their home QTH fields if they're still empty.
Enqueued automatically on `Accounts.register_user/1` and reusable for
backfilling existing users via:
Microwaveprop.Repo.all(
from u in Microwaveprop.Accounts.User, where: is_nil(u.home_grid)
)
|> Enum.each(fn user ->
%{user_id: user.id}
|> Microwaveprop.Workers.UserHomeQthLookupWorker.new()
|> Oban.insert()
end)
"""
use Oban.Worker, queue: :weather, max_attempts: 3
alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
case Repo.get(User, user_id) do
nil ->
:ok
%User{home_grid: grid} when is_binary(grid) and grid != "" ->
# User already set their home QTH manually; don't overwrite.
:ok
%User{callsign: nil} ->
:ok
%User{callsign: callsign} = user ->
do_lookup(user, callsign)
end
end
defp do_lookup(user, callsign) do
case CallsignClient.locate(callsign) do
{:ok, %{lat: lat, lon: lon} = result} ->
grid = result[:gridsquare] || Maidenhead.from_latlon(lat, lon, 10)
attrs = %{"home_grid" => grid, "home_lat" => lat, "home_lon" => lon}
case Accounts.update_user_home_qth(user, attrs) do
{:ok, _user} ->
:ok
{:error, changeset} ->
Logger.warning(
"UserHomeQthLookupWorker save failed: callsign=#{inspect(callsign)} errors=#{inspect(changeset.errors)}"
)
:ok
end
{:error, reason} ->
Logger.info("UserHomeQthLookupWorker lookup failed for #{inspect(callsign)}: #{inspect(reason)}")
:ok
end
end
end

View file

@ -32,6 +32,17 @@
Used to anchor the rover map (drive radius, distance/bearing labels).
Enter a Maidenhead grid (e.g. <code>EM13qc</code>) or explicit lat/lon.
</p>
<div
:if={!@current_scope.user.home_grid}
class="alert alert-warning text-xs mt-2"
>
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" />
<span>
Using the default NTMS-area location (EM13).
A QRZ lookup for <code>{@current_scope.user.callsign}</code>
is queued — check back shortly, or set yours manually below.
</span>
</div>
</div>
<.input

View file

@ -0,0 +1,62 @@
defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Workers.UserHomeQthLookupWorker
# The test config disables enqueueing on registration globally so the
# rest of the suite doesn't need a Mox stub. We don't toggle that here
# — these tests invoke the worker directly via `perform_job/2`, which
# is independent of the enqueue flag.
describe "perform/1" do
test "no-ops when the user already has a home_grid set" do
user =
user_fixture()
|> Ecto.Changeset.change(home_grid: "EM13qc", home_lat: 33.1, home_lon: -96.6)
|> Repo.update!()
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id})
reloaded = Repo.reload(user)
assert reloaded.home_grid == "EM13qc"
end
test "no-ops when the user no longer exists" do
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: Ecto.UUID.generate()})
end
test "populates home QTH from a successful CallsignClient lookup" do
user = user_fixture()
# Stub QRZ.com HTTP layer to return coords for the user's callsign.
# The callsign-location pipeline reads either lat/lon or grid from
# the QRZ XML; we serve coords directly so the geocoder fallback
# isn't exercised.
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase>
<Session><Key>test-session</Key></Session>
<Callsign>
<call>#{user.callsign}</call>
<lat>33.10</lat>
<lon>-96.625</lon>
<grid>EM13qc</grid>
</Callsign>
</QRZDatabase>
""")
end)
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id})
reloaded = Repo.reload(user)
assert is_binary(reloaded.home_grid)
assert String.starts_with?(reloaded.home_grid, "EM13qc")
assert is_float(reloaded.home_lat)
assert is_float(reloaded.home_lon)
end
end
end