Beacon improvements: anon submit, notes, /map nav, admin backfill

- Anonymous users can submit beacons (held pending admin approval)
- Added notes field to beacons (textarea on form, shown on detail page)
- Added Beacons link to /map sidebar nav (mobile + desktop),
  removed stale Rover Planner link
- Moved /backfill to /admin/backfill under the admin live_session
- Beacon detail map zooms in two extra levels after fitBounds
This commit is contained in:
Graham McIntire 2026-04-08 16:23:46 -05:00
parent 4323f81b97
commit ba0f1161a7
11 changed files with 93 additions and 32 deletions

View file

@ -56,9 +56,11 @@ export const BeaconMap = {
offset: [0, -10] offset: [0, -10]
}) })
// Fit to the rendered cells (initial view already set above). // Fit to the rendered cells (initial view already set above), then zoom
// in two steps so the beacon's immediate surroundings are visible.
if (allBounds.length > 0) { if (allBounds.length > 0) {
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]}) map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2)
} }
setTimeout(() => map.invalidateSize(), 50) setTimeout(() => map.invalidateSize(), 50)

View file

@ -1,8 +1,8 @@
defmodule Microwaveprop.Beacons do defmodule Microwaveprop.Beacons do
@moduledoc """ @moduledoc """
The Beacons context. Any authenticated user can submit a beacon, The Beacons context. Anyone can submit a beacon authenticated or not
but submissions are held as unapproved until an admin approves but submissions are held as unapproved until an admin approves them.
them. Only approved beacons appear in the public list. Only approved beacons appear in the public list.
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
@ -50,8 +50,11 @@ defmodule Microwaveprop.Beacons do
def get_beacon!(id), do: Repo.get!(Beacon, id) def get_beacon!(id), do: Repo.get!(Beacon, id)
@doc """ @doc """
Creates a beacon. The user is recorded on the row as the creator. Creates a beacon. When a user is provided they are recorded as the
creator; anonymous submissions pass `nil` and leave `user_id` unset.
""" """
def create_beacon(user, attrs)
def create_beacon(%User{} = user, attrs) do def create_beacon(%User{} = user, attrs) do
%Beacon{user_id: user.id} %Beacon{user_id: user.id}
|> Beacon.changeset(attrs) |> Beacon.changeset(attrs)
@ -59,6 +62,13 @@ defmodule Microwaveprop.Beacons do
|> broadcast_if_ok(:created) |> broadcast_if_ok(:created)
end end
def create_beacon(nil, attrs) do
%Beacon{}
|> Beacon.changeset(attrs)
|> Repo.insert()
|> broadcast_if_ok(:created)
end
@doc "Updates a beacon." @doc "Updates a beacon."
def update_beacon(%Beacon{} = beacon, attrs) do def update_beacon(%Beacon{} = beacon, attrs) do
beacon beacon

View file

@ -28,6 +28,7 @@ defmodule Microwaveprop.Beacons.Beacon do
field :on_the_air, :boolean, default: true field :on_the_air, :boolean, default: true
field :approved, :boolean, default: false field :approved, :boolean, default: false
field :keying, :string, default: "on_off" field :keying, :string, default: "on_off"
field :notes, :string
field :user_id, :binary_id field :user_id, :binary_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
@ -46,7 +47,8 @@ defmodule Microwaveprop.Beacons.Beacon do
:power_mw, :power_mw,
:height_ft, :height_ft,
:on_the_air, :on_the_air,
:keying :keying,
:notes
]) ])
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end) |> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|> maybe_fill_latlon() |> maybe_fill_latlon()

View file

@ -51,6 +51,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
required required
/> />
<.input field={@form[:on_the_air]} type="checkbox" label="On the air" /> <.input field={@form[:on_the_air]} type="checkbox" label="On the air" />
<.input field={@form[:notes]} type="textarea" label="Notes" />
<footer> <footer>
<.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button> <.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button>
<.button navigate={return_path(@return_to, @beacon)}>Cancel</.button> <.button navigate={return_path(@return_to, @beacon)}>Cancel</.button>
@ -113,13 +114,18 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
end end
defp save_beacon(socket, :new, beacon_params) do defp save_beacon(socket, :new, beacon_params) do
user = socket.assigns.current_scope.user user = current_user(socket.assigns.current_scope)
case Beacons.create_beacon(user, beacon_params) do case Beacons.create_beacon(user, beacon_params) do
{:ok, beacon} -> {:ok, beacon} ->
flash =
if user,
do: "Beacon submitted — pending admin approval.",
else: "Thanks! Your beacon has been submitted and is awaiting admin approval."
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Beacon created") |> put_flash(:info, flash)
|> push_navigate(to: return_path(socket.assigns.return_to, beacon))} |> push_navigate(to: return_path(socket.assigns.return_to, beacon))}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
@ -127,6 +133,9 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
end end
end end
defp current_user(%{user: user}), do: user
defp current_user(_), do: nil
defp return_path("index", _beacon), do: ~p"/beacons" defp return_path("index", _beacon), do: ~p"/beacons"
defp return_path("show", beacon), do: ~p"/beacons/#{beacon}" defp return_path("show", beacon), do: ~p"/beacons/#{beacon}"
end end

View file

@ -12,11 +12,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
Beacons Beacons
<:subtitle>Microwave beacons tracked by NTMS.</:subtitle> <:subtitle>Microwave beacons tracked by NTMS.</:subtitle>
<:actions> <:actions>
<.button <.button variant="primary" navigate={~p"/beacons/new"}>
:if={authenticated?(@current_scope)}
variant="primary"
navigate={~p"/beacons/new"}
>
<.icon name="hero-plus" /> Submit Beacon <.icon name="hero-plus" /> Submit Beacon
</.button> </.button>
</:actions> </:actions>
@ -166,9 +162,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
defp admin?(%{user: %{is_admin: true}}), do: true defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false defp admin?(_), do: false
defp authenticated?(%{user: %{}}), do: true
defp authenticated?(_), do: false
defp keying_label("on_off"), do: "On/Off" defp keying_label("on_off"), do: "On/Off"
defp keying_label("fsk"), do: "FSK" defp keying_label("fsk"), do: "FSK"
defp keying_label(other), do: other defp keying_label(other), do: other

View file

@ -96,6 +96,9 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item> <:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
<:item title="Keying">{keying_label(@beacon.keying)}</:item> <:item title="Keying">{keying_label(@beacon.keying)}</:item>
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item> <:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
<:item :if={@beacon.notes && @beacon.notes != ""} title="Notes">
<div class="whitespace-pre-wrap">{@beacon.notes}</div>
</:item>
</.list> </.list>
</Layouts.app> </Layouts.app>
""" """

View file

@ -359,8 +359,8 @@ defmodule MicrowavepropWeb.MapLive do
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start"> <.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator Path Calculator
</.link> </.link>
<.link navigate="/rover" class="btn btn-xs btn-ghost justify-start"> <.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Rover Planner Beacons
</.link> </.link>
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start"> <.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
Weather Map Weather Map
@ -500,7 +500,7 @@ defmodule MicrowavepropWeb.MapLive do
<.link navigate="/path">Path Calculator</.link> <.link navigate="/path">Path Calculator</.link>
</li> </li>
<li> <li>
<.link navigate="/rover">Rover Planner</.link> <.link navigate="/beacons">Beacons</.link>
</li> </li>
<li> <li>
<.link navigate="/weather">Weather Map</.link> <.link navigate="/weather">Weather Map</.link>

View file

@ -27,19 +27,16 @@ defmodule MicrowavepropWeb.Router do
# Health check — no pipeline, minimal overhead # Health check — no pipeline, minimal overhead
get "/health", MicrowavepropWeb.HealthController, :check, log: false get "/health", MicrowavepropWeb.HealthController, :check, log: false
# Authenticated (non-admin) and admin-only routes must come first so that # Admin-only routes must come first so that literal segments like
# literal segments like `/beacons/new` are registered before the public # `/beacons/:id/edit` are registered before the public `/beacons/:id`.
# `/beacons/:id`.
scope "/", MicrowavepropWeb do scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user] pipe_through [:browser, :require_authenticated_user]
live_session :authenticated, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/beacons/new", BeaconLive.Form, :new
end
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
live "/beacons/:id/edit", BeaconLive.Form, :edit live "/beacons/:id/edit", BeaconLive.Form, :edit
live "/admin/backfill", BackfillLive
live "/users", UserManagementLive.Index, :index live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit live "/users/:id/edit", UserManagementLive.Edit, :edit
end end
@ -61,10 +58,10 @@ defmodule MicrowavepropWeb.Router do
live "/rover", RoverLive live "/rover", RoverLive
live "/algo", AlgoLive live "/algo", AlgoLive
live "/about", AboutLive live "/about", AboutLive
live "/backfill", BackfillLive
# Beacons: read-only public views # Beacons: public read + submit (pending admin approval)
live "/beacons", BeaconLive.Index, :index live "/beacons", BeaconLive.Index, :index
live "/beacons/new", BeaconLive.Form, :new
live "/beacons/:id", BeaconLive.Show, :show live "/beacons/:id", BeaconLive.Show, :show
end end

View file

@ -0,0 +1,9 @@
defmodule Microwaveprop.Repo.Migrations.AddNotesToBeacons do
use Ecto.Migration
def change do
alter table(:beacons) do
add :notes, :text
end
end
end

View file

@ -74,6 +74,13 @@ defmodule Microwaveprop.BeaconsTest do
assert beacon.user_id == user.id assert beacon.user_id == user.id
end end
test "anonymous submission creates a beacon with no user_id, unapproved" do
attrs = valid_beacon_attrs()
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(nil, attrs)
assert beacon.user_id == nil
assert beacon.approved == false
end
test "new beacons default to unapproved" do test "new beacons default to unapproved" do
user = user_fixture() user = user_fixture()
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs()) assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
@ -156,6 +163,13 @@ defmodule Microwaveprop.BeaconsTest do
assert "is invalid" in errors_on(changeset).keying assert "is invalid" in errors_on(changeset).keying
end end
test "stores notes" do
user = user_fixture()
notes = "Antenna 20 ft AGL, horizontal polarization"
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(notes: notes))
assert beacon.notes == notes
end
test "upcases the callsign" do test "upcases the callsign" do
user = user_fixture() user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn")) {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))

View file

@ -36,9 +36,9 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
refute html =~ "W5PEN" refute html =~ "W5PEN"
end end
test "anonymous does not see the Submit Beacon button", %{conn: conn} do test "anonymous sees the Submit Beacon button", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/beacons") {:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "Submit Beacon" assert html =~ "Submit Beacon"
end end
test "authenticated user sees the Submit Beacon button", %{conn: conn} do test "authenticated user sees the Submit Beacon button", %{conn: conn} do
@ -102,8 +102,30 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
assert html =~ "New beacon" assert html =~ "New beacon"
end end
test "redirects anonymous user away from /beacons/new", %{conn: conn} do test "anonymous user can access /beacons/new", %{conn: conn} do
assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/beacons/new") {:ok, _view, html} = live(conn, ~p"/beacons/new")
assert html =~ "New beacon"
end
test "anonymous submission creates an unapproved beacon with no user_id", %{conn: conn} do
{:ok, form_live, _html} = live(conn, ~p"/beacons/new")
attrs = valid_beacon_attrs(callsign: "W5ANON")
assert {:ok, _view, html} =
form_live
|> form("#beacon-form", beacon: attrs)
|> render_submit()
|> follow_redirect(conn, ~p"/beacons")
assert html =~ "awaiting admin approval"
beacon =
Enum.find(Microwaveprop.Beacons.list_pending_beacons(), fn b -> b.callsign == "W5ANON" end)
assert beacon
assert beacon.user_id == nil
assert beacon.approved == false
end end
test "non-admin submission creates an unapproved beacon", %{conn: conn} do test "non-admin submission creates an unapproved beacon", %{conn: conn} do