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]
})
// 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) {
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2)
}
setTimeout(() => map.invalidateSize(), 50)

View file

@ -1,8 +1,8 @@
defmodule Microwaveprop.Beacons do
@moduledoc """
The Beacons context. Any authenticated user can submit a beacon,
but submissions are held as unapproved until an admin approves
them. Only approved beacons appear in the public list.
The Beacons context. Anyone can submit a beacon authenticated or not
but submissions are held as unapproved until an admin approves them.
Only approved beacons appear in the public list.
"""
import Ecto.Query, warn: false
@ -50,8 +50,11 @@ defmodule Microwaveprop.Beacons do
def get_beacon!(id), do: Repo.get!(Beacon, id)
@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
%Beacon{user_id: user.id}
|> Beacon.changeset(attrs)
@ -59,6 +62,13 @@ defmodule Microwaveprop.Beacons do
|> broadcast_if_ok(:created)
end
def create_beacon(nil, attrs) do
%Beacon{}
|> Beacon.changeset(attrs)
|> Repo.insert()
|> broadcast_if_ok(:created)
end
@doc "Updates a beacon."
def update_beacon(%Beacon{} = beacon, attrs) do
beacon

View file

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

View file

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

View file

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

View file

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

View file

@ -27,19 +27,16 @@ defmodule MicrowavepropWeb.Router do
# Health check — no pipeline, minimal overhead
get "/health", MicrowavepropWeb.HealthController, :check, log: false
# Authenticated (non-admin) and admin-only routes must come first so that
# literal segments like `/beacons/new` are registered before the public
# `/beacons/:id`.
# Admin-only routes must come first so that literal segments like
# `/beacons/:id/edit` are registered before the public `/beacons/:id`.
scope "/", MicrowavepropWeb do
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 "/beacons/:id/edit", BeaconLive.Form, :edit
live "/admin/backfill", BackfillLive
live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit
end
@ -61,10 +58,10 @@ defmodule MicrowavepropWeb.Router do
live "/rover", RoverLive
live "/algo", AlgoLive
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/new", BeaconLive.Form, :new
live "/beacons/:id", BeaconLive.Show, :show
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
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
user = user_fixture()
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
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
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))

View file

@ -36,9 +36,9 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
refute html =~ "W5PEN"
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")
refute html =~ "Submit Beacon"
assert html =~ "Submit Beacon"
end
test "authenticated user sees the Submit Beacon button", %{conn: conn} do
@ -102,8 +102,30 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
assert html =~ "New beacon"
end
test "redirects anonymous user away from /beacons/new", %{conn: conn} do
assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/beacons/new")
test "anonymous user can access /beacons/new", %{conn: conn} do
{: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
test "non-admin submission creates an unapproved beacon", %{conn: conn} do