diff --git a/config/test.exs b/config/test.exs index 21392632..ea4eae2b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index 8c66041f..dd33c6f2 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -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 """ diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index d7b2b1cf..f09a4a96 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -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() diff --git a/lib/microwaveprop/workers/user_home_qth_lookup_worker.ex b/lib/microwaveprop/workers/user_home_qth_lookup_worker.ex new file mode 100644 index 00000000..e6a5772a --- /dev/null +++ b/lib/microwaveprop/workers/user_home_qth_lookup_worker.ex @@ -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 diff --git a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex index 9cc1e595..e7758ecb 100644 --- a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex +++ b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex @@ -32,6 +32,17 @@ Used to anchor the rover map (drive radius, distance/bearing labels). Enter a Maidenhead grid (e.g. EM13qc) or explicit lat/lon.

+
+ <.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> + + Using the default NTMS-area location (EM13). + A QRZ lookup for {@current_scope.user.callsign} + is queued — check back shortly, or set yours manually below. + +
<.input diff --git a/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs b/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs new file mode 100644 index 00000000..cf8aaa5c --- /dev/null +++ b/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs @@ -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, """ + + + test-session + + #{user.callsign} + 33.10 + -96.625 + EM13qc + + + """) + 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