Beacon submission approval workflow

Non-admin users can now submit beacons but they are held in an
unapproved state until an admin approves them. Only approved beacons
appear in the public list; pending submissions are shown in a separate
admin-only section on /beacons with Approve and Delete actions. The
show page surfaces a pending badge and an admin-only Approve button
when viewing an unapproved beacon.

Also formats EIRP (mW) on the index page without scientific notation.
This commit is contained in:
Graham McIntire 2026-04-08 14:36:42 -05:00
parent f8763feb38
commit 80f2725cd5
9 changed files with 304 additions and 42 deletions

View file

@ -1,8 +1,8 @@
defmodule Microwaveprop.Beacons do
@moduledoc """
The Beacons context. Beacon records are public (anyone can read)
but only admin users can create/update/delete them that gating
is enforced by the router's live_session for the form views.
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.
"""
import Ecto.Query, warn: false
@ -28,9 +28,22 @@ defmodule Microwaveprop.Beacons do
Phoenix.PubSub.broadcast(Microwaveprop.PubSub, @topic, message)
end
@doc "Returns all beacons ordered by frequency."
@doc "Returns approved beacons ordered by frequency."
def list_beacons do
Repo.all(from b in Beacon, order_by: [asc: b.frequency_mhz, asc: b.callsign])
Repo.all(
from b in Beacon,
where: b.approved == true,
order_by: [asc: b.frequency_mhz, asc: b.callsign]
)
end
@doc "Returns unapproved beacons awaiting admin review."
def list_pending_beacons do
Repo.all(
from b in Beacon,
where: b.approved == false,
order_by: [asc: b.inserted_at]
)
end
@doc "Gets a single beacon. Raises if not found."
@ -54,6 +67,14 @@ defmodule Microwaveprop.Beacons do
|> broadcast_if_ok(:updated)
end
@doc "Marks a beacon as approved, making it visible in the public list."
def approve_beacon(%Beacon{} = beacon) do
beacon
|> Ecto.Changeset.change(approved: true)
|> Repo.update()
|> broadcast_if_ok(:updated)
end
@doc "Deletes a beacon."
def delete_beacon(%Beacon{} = beacon) do
case Repo.delete(beacon) do

View file

@ -22,6 +22,7 @@ defmodule Microwaveprop.Beacons.Beacon do
field :power_mw, :float
field :height_ft, :float
field :on_the_air, :boolean, default: true
field :approved, :boolean, default: false
field :user_id, :binary_id
timestamps(type: :utc_datetime)

View file

@ -48,8 +48,7 @@ defmodule Microwaveprop.Beacons.RangeEstimate do
"""
@spec nearest_band_mhz(number()) :: integer()
def nearest_band_mhz(freq_mhz) when is_number(freq_mhz) do
BandConfig.all_freqs()
|> Enum.min_by(fn b -> abs(b - freq_mhz) end)
Enum.min_by(BandConfig.all_freqs(), fn b -> abs(b - freq_mhz) end)
end
@doc """

View file

@ -12,8 +12,12 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
Beacons
<:subtitle>Microwave beacons tracked by NTMS.</:subtitle>
<:actions>
<.button :if={admin?(@current_scope)} variant="primary" navigate={~p"/beacons/new"}>
<.icon name="hero-plus" /> New Beacon
<.button
:if={authenticated?(@current_scope)}
variant="primary"
navigate={~p"/beacons/new"}
>
<.icon name="hero-plus" /> Submit Beacon
</.button>
</:actions>
</.header>
@ -28,10 +32,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="Lat">{beacon.lat}</:col>
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{beacon.power_mw}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="On air">
<span class={["badge badge-sm", beacon.on_the_air && "badge-success" || "badge-ghost"]}>
<span class={["badge badge-sm", (beacon.on_the_air && "badge-success") || "badge-ghost"]}>
{if beacon.on_the_air, do: "Yes", else: "No"}
</span>
</:col>
@ -51,6 +55,44 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
</.link>
</:action>
</.table>
<div :if={admin?(@current_scope) and @pending != []} class="mt-10">
<.header>
Pending approval
<:subtitle>Submitted beacons waiting for admin review.</:subtitle>
</.header>
<.table
id="pending-beacons"
rows={@streams.pending}
row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end}
>
<:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz}</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Submitted">
{Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")}
</:col>
<:action :let={{_id, beacon}}>
<.link
phx-click={JS.push("approve", value: %{id: beacon.id})}
data-confirm="Approve this beacon?"
>
Approve
</.link>
</:action>
<:action :let={{id, beacon}}>
<.link
phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")}
data-confirm="Delete this beacon?"
>
Delete
</.link>
</:action>
</.table>
</div>
</Layouts.app>
"""
end
@ -59,10 +101,19 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
def mount(_params, _session, socket) do
if connected?(socket), do: Beacons.subscribe_beacons()
pending =
if admin?(socket.assigns.current_scope) do
Beacons.list_pending_beacons()
else
[]
end
{:ok,
socket
|> assign(:page_title, "Beacons")
|> stream(:beacons, Beacons.list_beacons())}
|> assign(:pending, pending)
|> stream(:beacons, Beacons.list_beacons())
|> stream(:pending, pending)}
end
@impl true
@ -70,7 +121,26 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, _} = Beacons.delete_beacon(beacon)
{:noreply, stream_delete(socket, :beacons, beacon)}
{:noreply,
socket
|> stream_delete(:beacons, beacon)
|> stream_delete(:pending, beacon)}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
def handle_event("approve", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, approved} = Beacons.approve_beacon(beacon)
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, beacon)
|> stream_insert(:beacons, approved)}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
@ -78,9 +148,45 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
@impl true
def handle_info({type, %Microwaveprop.Beacons.Beacon{}}, socket) when type in [:created, :updated, :deleted] do
{:noreply, stream(socket, :beacons, Beacons.list_beacons(), reset: true)}
pending =
if admin?(socket.assigns.current_scope) do
Beacons.list_pending_beacons()
else
[]
end
{:noreply,
socket
|> assign(:pending, pending)
|> stream(:beacons, Beacons.list_beacons(), reset: true)
|> stream(:pending, pending, reset: true)}
end
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
defp authenticated?(%{user: %{}}), do: true
defp authenticated?(_), do: false
# Format a milliwatt power value without scientific notation.
# Keeps up to 3 decimal places, drops trailing zeros.
defp format_mw(nil), do: ""
defp format_mw(mw) when is_float(mw) do
if mw == trunc(mw) do
Integer.to_string(trunc(mw))
else
mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
end
end
defp format_mw(mw), do: to_string(mw)
defp trim_trailing_zeros(str) do
if String.contains?(str, ".") do
str |> String.trim_trailing("0") |> String.trim_trailing(".")
else
str
end
end
end

View file

@ -12,8 +12,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
{@beacon.callsign}
<:subtitle>{@beacon.frequency_mhz} MHz &middot; {@beacon.grid}</:subtitle>
<:subtitle>
{@beacon.frequency_mhz} MHz &middot; {@beacon.grid}
<span :if={not @beacon.approved} class="badge badge-warning badge-sm ml-2">
Pending approval
</span>
</:subtitle>
<:actions>
<.button
:if={admin?(@current_scope) and not @beacon.approved}
variant="primary"
phx-click="approve"
>
<.icon name="hero-check" /> Approve
</.button>
<.button navigate={~p"/beacons"}>
<.icon name="hero-arrow-left" />
</.button>
@ -54,7 +66,8 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<% else %>
(no HRRR data defaulting cells to 50)
<% end %>
&middot; <strong>{length(@estimate.cells)}</strong> cells rendered
&middot; <strong>{length(@estimate.cells)}</strong>
cells rendered
</span>
</div>
@ -67,8 +80,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
>
</span>
<span>
<strong>{tier.label}</strong>
( {tier.min_dbm} dBm)
<strong>{tier.label}</strong> ( {tier.min_dbm} dBm)
</span>
</div>
<% end %>
@ -101,6 +113,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|> assign(:estimate, RangeEstimate.estimate(beacon))}
end
@impl true
def handle_event("approve", _params, socket) do
if admin?(socket.assigns.current_scope) do
{:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon)
{:noreply,
socket
|> assign(:beacon, approved)
|> put_flash(:info, "Beacon approved.")}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
@impl true
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
{:noreply,

View file

@ -27,13 +27,17 @@ defmodule MicrowavepropWeb.Router do
# Health check — no pipeline, minimal overhead
get "/health", MicrowavepropWeb.HealthController, :check, log: false
# Admin-only routes must come first so that literal segments like
# `/beacons/new` are registered before the public `/beacons/:id`.
# Authenticated (non-admin) and admin-only routes must come first so that
# literal segments like `/beacons/new` are registered before the public
# `/beacons/:id`.
scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
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 "/users", UserManagementLive.Index, :index

View file

@ -0,0 +1,12 @@
defmodule Microwaveprop.Repo.Migrations.AddApprovedToBeacons do
use Ecto.Migration
def change do
alter table(:beacons) do
add :approved, :boolean, null: false, default: false
end
# Existing beacons were admin-created, so mark them approved.
execute "UPDATE beacons SET approved = true", ""
end
end

View file

@ -18,12 +18,36 @@ defmodule Microwaveprop.BeaconsTest do
}
describe "list_beacons/0" do
test "returns all beacons regardless of creator" do
test "returns only approved beacons" do
u1 = user_fixture()
u2 = user_fixture()
b1 = beacon_fixture(u1)
b2 = beacon_fixture(u2, callsign: "W5TX")
assert [^b1, ^b2] = Enum.sort_by(Beacons.list_beacons(), & &1.callsign)
{:ok, b1} = Beacons.create_beacon(u1, valid_beacon_attrs())
{:ok, b1} = Beacons.approve_beacon(b1)
_pending = beacon_fixture(u2, callsign: "W5TX")
assert [^b1] = Beacons.list_beacons()
end
end
describe "list_pending_beacons/0" do
test "returns only unapproved beacons" do
u1 = user_fixture()
u2 = user_fixture()
{:ok, approved} = Beacons.create_beacon(u1, valid_beacon_attrs())
{:ok, _approved} = Beacons.approve_beacon(approved)
pending = beacon_fixture(u2, callsign: "W5TX")
assert [^pending] = Beacons.list_pending_beacons()
end
end
describe "approve_beacon/1" do
test "marks the beacon as approved and broadcasts an update" do
user = user_fixture()
beacon = beacon_fixture(user)
refute beacon.approved
Beacons.subscribe_beacons()
assert {:ok, approved} = Beacons.approve_beacon(beacon)
assert approved.approved == true
assert_receive {:updated, %Beacon{approved: true}}
end
end
@ -50,6 +74,19 @@ defmodule Microwaveprop.BeaconsTest do
assert beacon.user_id == user.id
end
test "new beacons default to unapproved" do
user = user_fixture()
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.approved == false
end
test "ignores client-supplied approved value" do
user = user_fixture()
attrs = Map.put(valid_beacon_attrs(), :approved, true)
assert {:ok, beacon} = Beacons.create_beacon(user, attrs)
assert beacon.approved == false
end
test "derives grid from lat/lon when grid is omitted" do
user = user_fixture()
# DFW is EM12
@ -61,7 +98,8 @@ defmodule Microwaveprop.BeaconsTest do
user = user_fixture()
attrs =
valid_beacon_attrs(grid: "EM12kp")
[grid: "EM12kp"]
|> valid_beacon_attrs()
|> Map.drop([:lat, :lon])
assert {:ok, beacon} = Beacons.create_beacon(user, attrs)

View file

@ -16,63 +16,112 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
promote_to_admin(user_fixture())
end
defp approved_beacon_fixture(user, attrs \\ %{}) do
beacon = beacon_fixture(user, attrs)
{:ok, beacon} = Microwaveprop.Beacons.approve_beacon(beacon)
beacon
end
describe "Index (public)" do
test "lists beacons without logging in", %{conn: conn} do
beacon = beacon_fixture(user_fixture())
test "lists approved beacons without logging in", %{conn: conn} do
beacon = approved_beacon_fixture(user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "Beacons"
assert html =~ beacon.callsign
end
test "non-admin does not see the New Beacon button", %{conn: conn} do
test "hides unapproved beacons from the public list", %{conn: conn} do
_pending = beacon_fixture(user_fixture(), callsign: "W5PEN")
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "W5PEN"
end
test "anonymous does not see the Submit Beacon button", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "Submit Beacon"
end
test "authenticated user sees the Submit Beacon button", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "New Beacon"
assert html =~ "Submit Beacon"
end
test "admin sees the New Beacon button", %{conn: conn} do
test "admin sees pending beacons section", %{conn: conn} do
pending = beacon_fixture(user_fixture(), callsign: "W5PEN")
conn = log_in_user(conn, admin_user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "New Beacon"
assert html =~ "Pending approval"
assert html =~ pending.callsign
end
test "non-admin does not see pending beacons section", %{conn: conn} do
_pending = beacon_fixture(user_fixture(), callsign: "W5PEN")
conn = log_in_user(conn, user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "Pending approval"
refute html =~ "W5PEN"
end
test "admin can approve a pending beacon", %{conn: conn} do
pending = beacon_fixture(user_fixture(), callsign: "W5PEN")
conn = log_in_user(conn, admin_user_fixture())
{:ok, view, _html} = live(conn, ~p"/beacons")
html =
view
|> element("a[phx-click][data-confirm='Approve this beacon?']")
|> render_click()
assert html =~ "Approved #{pending.callsign}"
assert Microwaveprop.Beacons.get_beacon!(pending.id).approved == true
end
end
describe "Show (public)" do
test "renders beacon details without logging in", %{conn: conn} do
beacon = beacon_fixture(user_fixture())
beacon = approved_beacon_fixture(user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ beacon.callsign
assert html =~ "#{beacon.frequency_mhz}"
end
test "shows pending badge on unapproved beacons", %{conn: conn} do
beacon = beacon_fixture(user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ "Pending approval"
end
end
describe "Form (admin only)" do
test "redirects non-admin away from /beacons/new", %{conn: conn} do
describe "Form" do
test "authenticated non-admin can access /beacons/new", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/new")
{:ok, _view, html} = live(conn, ~p"/beacons/new")
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")
end
test "admin creates a beacon", %{conn: conn} do
conn = log_in_user(conn, admin_user_fixture())
test "non-admin submission creates an unapproved beacon", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, form_live, _html} = live(conn, ~p"/beacons/new")
attrs = valid_beacon_attrs()
assert {:ok, _view, html} =
assert {:ok, _view, _html} =
form_live
|> form("#beacon-form", beacon: attrs)
|> render_submit()
|> follow_redirect(conn, ~p"/beacons")
assert html =~ "Beacon created"
assert html =~ attrs.callsign
beacon = List.first(Microwaveprop.Beacons.list_pending_beacons())
assert beacon.callsign == attrs.callsign
assert beacon.approved == false
end
test "admin edits a beacon", %{conn: conn} do
@ -90,5 +139,11 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
assert html =~ "Beacon updated"
end
test "non-admin cannot access /beacons/:id/edit", %{conn: conn} do
beacon = beacon_fixture(user_fixture())
conn = log_in_user(conn, user_fixture())
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/#{beacon}/edit")
end
end
end