Add /beacons CRUD and /users admin page

Beacons:
- Scaffolded with phx.gen.live then reworked so reads are public
  and mutations go through a live_session gated by the new
  :require_admin on_mount hook in UserAuth
- Beacon schema stores frequency (MHz), callsign, grid, lat/lon,
  power (W), and height above ground (m); grid auto-derives from
  lat/lon when left blank via Maidenhead.from_latlon
- Adds Maidenhead.from_latlon/3 so we can compute grids locally
  instead of hitting an external API

Users admin page:
- /users and /users/:id/edit (admin-only) for listing, editing
  (callsign/name/email/is_admin), and deleting other users
- Adds Accounts.list_users, admin_update_user, delete_user, and
  a dedicated admin_changeset on the User schema
- Nav gains a "Users" link for admins and a "Beacons" link for
  everyone
This commit is contained in:
Graham McIntire 2026-04-08 12:01:34 -05:00
parent ea5fdd6479
commit ddec874c38
19 changed files with 1071 additions and 0 deletions

View file

@ -61,6 +61,28 @@ defmodule Microwaveprop.Accounts do
"""
def get_user!(id), do: Repo.get!(User, id)
## Admin user management
@doc "Returns all users ordered by callsign."
def list_users do
Repo.all(from u in User, order_by: [asc: u.callsign])
end
@doc "Updates admin-managed user fields (callsign, name, email, is_admin)."
def admin_update_user(%User{} = user, attrs) do
user
|> User.admin_changeset(attrs)
|> Repo.update()
end
@doc "Returns an admin-edit changeset for rendering forms."
def change_admin_user(%User{} = user, attrs \\ %{}) do
User.admin_changeset(user, attrs)
end
@doc "Deletes a user."
def delete_user(%User{} = user), do: Repo.delete(user)
## User registration
@doc """

View file

@ -59,6 +59,25 @@ defmodule Microwaveprop.Accounts.User do
end
end
@doc """
Admin-only changeset for editing another user's profile fields
and admin flag. Does not touch the password.
"""
def admin_changeset(user, attrs) do
user
|> cast(attrs, [:callsign, :name, :email, :is_admin])
|> update_change(:callsign, fn cs -> cs && cs |> String.trim() |> String.upcase() end)
|> validate_required([:callsign, :name, :email])
|> validate_format(:callsign, ~r/^[A-Z0-9]{3,10}$/, message: "must be 3-10 letters and digits")
|> validate_length(:name, min: 1, max: 100)
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> unsafe_validate_unique(:callsign, Microwaveprop.Repo)
|> unique_constraint(:callsign)
|> unsafe_validate_unique(:email, Microwaveprop.Repo)
|> unique_constraint(:email)
end
defp validate_callsign(changeset, opts) do
changeset =
changeset

View file

@ -0,0 +1,80 @@
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.
"""
import Ecto.Query, warn: false
alias Microwaveprop.Accounts.User
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Repo
@topic "beacons"
@doc """
Subscribes to beacon change notifications. Messages:
* `{:created, %Beacon{}}`
* `{:updated, %Beacon{}}`
* `{:deleted, %Beacon{}}`
"""
def subscribe_beacons do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, @topic)
end
defp broadcast(message) do
Phoenix.PubSub.broadcast(Microwaveprop.PubSub, @topic, message)
end
@doc "Returns all beacons ordered by frequency."
def list_beacons do
Repo.all(from b in Beacon, order_by: [asc: b.frequency_mhz, asc: b.callsign])
end
@doc "Gets a single beacon. Raises if not found."
def get_beacon!(id), do: Repo.get!(Beacon, id)
@doc """
Creates a beacon. The user is recorded on the row as the creator.
"""
def create_beacon(%User{} = user, attrs) do
%Beacon{user_id: user.id}
|> Beacon.changeset(attrs)
|> Repo.insert()
|> broadcast_if_ok(:created)
end
@doc "Updates a beacon."
def update_beacon(%Beacon{} = beacon, attrs) do
beacon
|> Beacon.changeset(attrs)
|> Repo.update()
|> broadcast_if_ok(:updated)
end
@doc "Deletes a beacon."
def delete_beacon(%Beacon{} = beacon) do
case Repo.delete(beacon) do
{:ok, beacon} ->
broadcast({:deleted, beacon})
{:ok, beacon}
other ->
other
end
end
@doc "Returns an `%Ecto.Changeset{}` for tracking beacon changes."
def change_beacon(%Beacon{} = beacon, attrs \\ %{}) do
Beacon.changeset(beacon, attrs)
end
defp broadcast_if_ok({:ok, beacon} = result, type) do
broadcast({type, beacon})
result
end
defp broadcast_if_ok(other, _type), do: other
end

View file

@ -0,0 +1,86 @@
defmodule Microwaveprop.Beacons.Beacon do
@moduledoc """
A beacon transmitter. Frequency in MHz, coordinates as lat/lon,
grid as Maidenhead (auto-computed from lat/lon when not supplied),
power in watts, height above ground in meters.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "beacons" do
field :frequency_mhz, :float
field :callsign, :string
field :grid, :string
field :lat, :float
field :lon, :float
field :power_watts, :float
field :height_m, :float
field :user_id, :binary_id
timestamps(type: :utc_datetime)
end
@required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_watts, :height_m]
def changeset(beacon, attrs) do
beacon
|> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_watts, :height_m])
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|> validate_required(@required_fields)
|> validate_number(:frequency_mhz, greater_than: 0)
|> validate_number(:power_watts, greater_than_or_equal_to: 0)
|> validate_number(:height_m, greater_than_or_equal_to: 0)
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|> validate_length(:callsign, min: 3, max: 10)
|> maybe_fill_grid()
|> validate_grid_format()
end
# If grid is blank but lat/lon are valid, derive it.
defp maybe_fill_grid(changeset) do
grid = get_field(changeset, :grid)
lat = get_field(changeset, :lat)
lon = get_field(changeset, :lon)
if grid in [nil, ""] and is_number(lat) and is_number(lon) do
put_change(changeset, :grid, Maidenhead.from_latlon(lat, lon, 6))
else
changeset
end
end
defp validate_grid_format(changeset) do
case get_field(changeset, :grid) do
nil ->
add_error(changeset, :grid, "can't be blank")
grid ->
if Maidenhead.valid?(grid) do
update_change(changeset, :grid, &normalize_grid/1)
else
add_error(changeset, :grid, "is not a valid Maidenhead grid")
end
end
end
# Field uppercase, square digits, subsquare lowercase (standard form)
defp normalize_grid(nil), do: nil
defp normalize_grid(grid) do
grid = String.trim(grid)
len = String.length(grid)
if len >= 6 do
grid |> String.slice(0, 4) |> String.upcase() |> Kernel.<>(String.downcase(String.slice(grid, 4, len - 4)))
else
String.upcase(grid)
end
end
end

View file

@ -46,6 +46,53 @@ defmodule Microwaveprop.Radio.Maidenhead do
def to_latlon(_), do: :error
@doc """
Converts a lat/lon pair into a Maidenhead grid at the requested
precision (must be an even number between 4 and 8). Default precision
is 6 (subsquare level, ~5'×2.5', ~7 km × 4.6 km at the equator).
"""
@spec from_latlon(number(), number(), pos_integer()) :: String.t()
def from_latlon(lat, lon, precision \\ 6) when is_number(lat) and is_number(lon) and precision in [4, 6, 8] do
lat = lat + 90.0
lon = lon + 180.0
# Field (A-R), 20° lon / 10° lat
f1 = trunc(lon / 20.0)
f2 = trunc(lat / 10.0)
lon = lon - f1 * 20.0
lat = lat - f2 * 10.0
# Square (0-9), 2° lon / 1° lat
s1 = trunc(lon / 2.0)
s2 = trunc(lat / 1.0)
lon = lon - s1 * 2.0
lat = lat - s2 * 1.0
grid = <<?A + f1, ?A + f2, ?0 + s1, ?0 + s2>>
cond do
precision == 4 ->
grid
precision >= 6 ->
# Subsquare (a-x), 5'/60 lon, 2.5'/60 lat
ss1 = trunc(lon * 60.0 / 5.0)
ss2 = trunc(lat * 60.0 / 2.5)
lon = lon - ss1 * (5.0 / 60.0)
lat = lat - ss2 * (2.5 / 60.0)
grid = grid <> <<?a + ss1, ?a + ss2>>
if precision == 8 do
# Extended square (0-9), (5/60)/10 lon, (2.5/60)/10 lat
e1 = trunc(lon * 60.0 / 0.5)
e2 = trunc(lat * 60.0 / 0.25)
grid <> <<?0 + e1, ?0 + e2>>
else
grid
end
end
end
# Decode remaining alternating letter(24)/digit(10) pairs after field+square
defp decode_pairs([c1, c2 | rest], lat, lon, lat_size, lon_size, :letter) do
cell_lon = lon_size / 24

View file

@ -43,9 +43,17 @@ defmodule MicrowavepropWeb.Layouts do
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
<.link navigate="/rover" class="btn btn-ghost btn-sm">Rover</.link>
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
<.link
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
navigate="/users"
class="btn btn-ghost btn-sm"
>
Users
</.link>
<%= if @current_scope && @current_scope.user do %>
<span class="btn btn-ghost btn-sm pointer-events-none opacity-70">
{@current_scope.user.callsign}

View file

@ -0,0 +1,121 @@
defmodule MicrowavepropWeb.BeaconLive.Form do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
{@page_title}
<:subtitle>Leave the grid blank to auto-fill it from lat/lon.</:subtitle>
</.header>
<.form for={@form} id="beacon-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:frequency_mhz]}
type="number"
label="Frequency (MHz)"
step="any"
required
/>
<.input field={@form[:callsign]} type="text" label="Call" required />
<.input field={@form[:grid]} type="text" label="Grid (auto if blank)" />
<.input field={@form[:lat]} type="number" label="Latitude" step="any" required />
<.input field={@form[:lon]} type="number" label="Longitude" step="any" required />
<.input
field={@form[:power_watts]}
type="number"
label="Power (W)"
step="any"
required
/>
<.input
field={@form[:height_m]}
type="number"
label="Height above ground (m)"
step="any"
required
/>
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button>
<.button navigate={return_path(@return_to, @beacon)}>Cancel</.button>
</footer>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> apply_action(socket.assigns.live_action, params)}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
defp apply_action(socket, :edit, %{"id" => id}) do
beacon = Beacons.get_beacon!(id)
socket
|> assign(:page_title, "Edit beacon")
|> assign(:beacon, beacon)
|> assign(:form, to_form(Beacons.change_beacon(beacon)))
end
defp apply_action(socket, :new, _params) do
beacon = %Beacon{}
socket
|> assign(:page_title, "New beacon")
|> assign(:beacon, beacon)
|> assign(:form, to_form(Beacons.change_beacon(beacon)))
end
@impl true
def handle_event("validate", %{"beacon" => beacon_params}, socket) do
changeset = Beacons.change_beacon(socket.assigns.beacon, beacon_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"beacon" => beacon_params}, socket) do
save_beacon(socket, socket.assigns.live_action, beacon_params)
end
defp save_beacon(socket, :edit, beacon_params) do
case Beacons.update_beacon(socket.assigns.beacon, beacon_params) do
{:ok, beacon} ->
{:noreply,
socket
|> put_flash(:info, "Beacon updated")
|> push_navigate(to: return_path(socket.assigns.return_to, beacon))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_beacon(socket, :new, beacon_params) do
user = socket.assigns.current_scope.user
case Beacons.create_beacon(user, beacon_params) do
{:ok, beacon} ->
{:noreply,
socket
|> put_flash(:info, "Beacon created")
|> push_navigate(to: return_path(socket.assigns.return_to, beacon))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp return_path("index", _beacon), do: ~p"/beacons"
defp return_path("show", beacon), do: ~p"/beacons/#{beacon}"
end

View file

@ -0,0 +1,81 @@
defmodule MicrowavepropWeb.BeaconLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Beacons
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
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>
</:actions>
</.header>
<.table
id="beacons"
rows={@streams.beacons}
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="Lat">{beacon.lat}</:col>
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
<:col :let={{_id, beacon}} label="Power (W)">{beacon.power_watts}</:col>
<:col :let={{_id, beacon}} label="Height AGL (m)">{beacon.height_m}</:col>
<:action :let={{_id, beacon}}>
<div class="sr-only">
<.link navigate={~p"/beacons/#{beacon}"}>Show</.link>
</div>
<.link :if={admin?(@current_scope)} navigate={~p"/beacons/#{beacon}/edit"}>Edit</.link>
</:action>
<:action :let={{id, beacon}}>
<.link
:if={admin?(@current_scope)}
phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")}
data-confirm="Delete this beacon?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Beacons.subscribe_beacons()
{:ok,
socket
|> assign(:page_title, "Beacons")
|> stream(:beacons, Beacons.list_beacons())}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, _} = Beacons.delete_beacon(beacon)
{:noreply, stream_delete(socket, :beacons, beacon)}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
@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)}
end
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
end

View file

@ -0,0 +1,70 @@
defmodule MicrowavepropWeb.BeaconLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
{@beacon.callsign}
<:subtitle>{@beacon.frequency_mhz} MHz &middot; {@beacon.grid}</:subtitle>
<:actions>
<.button navigate={~p"/beacons"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
:if={admin?(@current_scope)}
variant="primary"
navigate={~p"/beacons/#{@beacon}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit
</.button>
</:actions>
</.header>
<.list>
<:item title="Frequency (MHz)">{@beacon.frequency_mhz}</:item>
<:item title="Callsign">{@beacon.callsign}</:item>
<:item title="Grid">{@beacon.grid}</:item>
<:item title="Latitude">{@beacon.lat}</:item>
<:item title="Longitude">{@beacon.lon}</:item>
<:item title="Power (W)">{@beacon.power_watts}</:item>
<:item title="Height above ground (m)">{@beacon.height_m}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
if connected?(socket), do: Beacons.subscribe_beacons()
{:ok,
socket
|> assign(:page_title, "Beacon")
|> assign(:beacon, Beacons.get_beacon!(id))}
end
@impl true
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
{:noreply, assign(socket, :beacon, beacon)}
end
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do
{:noreply,
socket
|> put_flash(:error, "This beacon was deleted.")
|> push_navigate(to: ~p"/beacons")}
end
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
{:noreply, socket}
end
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
end

View file

@ -0,0 +1,61 @@
defmodule MicrowavepropWeb.UserManagementLive.Edit do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Edit user
<:subtitle>{@user.callsign} &middot; {@user.email}</:subtitle>
</.header>
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:callsign]} type="text" label="Callsign" required />
<.input field={@form[:name]} type="text" label="Name" required />
<.input field={@form[:email]} type="email" label="Email" required />
<label class="flex items-center gap-2 cursor-pointer mt-4">
<.input field={@form[:is_admin]} type="checkbox" label="Admin" />
</label>
<footer class="mt-4 flex gap-2">
<.button variant="primary" phx-disable-with="Saving...">Save</.button>
<.button navigate={~p"/users"}>Cancel</.button>
</footer>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
user = Accounts.get_user!(id)
{:ok,
socket
|> assign(:page_title, "Edit user")
|> assign(:user, user)
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
end
@impl true
def handle_event("validate", %{"user" => params}, socket) do
changeset = Accounts.change_admin_user(socket.assigns.user, params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.admin_update_user(socket.assigns.user, params) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "User updated")
|> push_navigate(to: ~p"/users")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
end

View file

@ -0,0 +1,70 @@
defmodule MicrowavepropWeb.UserManagementLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Users
<:subtitle>Manage registered accounts and admin flags.</:subtitle>
</.header>
<.table id="users" rows={@streams.users}>
<:col :let={{_id, user}} label="Callsign">{user.callsign}</:col>
<:col :let={{_id, user}} label="Name">{user.name}</:col>
<:col :let={{_id, user}} label="Email">{user.email}</:col>
<:col :let={{_id, user}} label="Admin">
<%= if user.is_admin do %>
<span class="badge badge-primary badge-sm">admin</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</:col>
<:col :let={{_id, user}} label="Confirmed">
<%= if user.confirmed_at do %>
{Calendar.strftime(user.confirmed_at, "%Y-%m-%d")}
<% else %>
<span class="opacity-50">pending</span>
<% end %>
</:col>
<:action :let={{_id, user}}>
<.link navigate={~p"/users/#{user.id}/edit"}>Edit</.link>
</:action>
<:action :let={{id, user}}>
<.link
:if={user.id != @current_scope.user.id}
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("##{id}")}
data-confirm={"Delete user #{user.callsign}? This cannot be undone."}
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Users")
|> stream(:users, Accounts.list_users())}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
if user.id == socket.assigns.current_scope.user.id do
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
else
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :users, user)}
end
end
end

View file

@ -27,6 +27,20 @@ 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`.
scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
live "/beacons/new", BeaconLive.Form, :new
live "/beacons/:id/edit", BeaconLive.Form, :edit
live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit
end
end
scope "/", MicrowavepropWeb do
pipe_through :browser
@ -43,6 +57,10 @@ defmodule MicrowavepropWeb.Router do
live "/algo", AlgoLive
live "/backfill", BackfillLive
# Beacons: read-only public views
live "/beacons", BeaconLive.Index, :index
live "/beacons/:id", BeaconLive.Show, :show
# Redirect old /qsos routes
get "/qsos", PageController, :redirect_contacts
get "/qsos/:id", PageController, :redirect_contact

View file

@ -21,11 +21,30 @@ defmodule MicrowavepropWeb.UserAuth do
@doc """
LiveView `on_mount` callback that assigns `:current_scope` from the
session token so LiveView templates can show auth-aware navigation.
The `:require_admin` variant halts and redirects users who are not
logged in or not flagged as admin. Use it via `live_session`.
"""
def on_mount(:default, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
def on_mount(:require_admin, _params, session, socket) do
socket = mount_current_scope(socket, session)
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if user && user.is_admin do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must be an admin to access that page.")
|> Phoenix.LiveView.redirect(to: ~p"/")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
user =

View file

@ -0,0 +1,21 @@
defmodule Microwaveprop.Repo.Migrations.CreateBeacons do
use Ecto.Migration
def change do
create table(:beacons, primary_key: false) do
add :id, :binary_id, primary_key: true
add :frequency_mhz, :float
add :callsign, :string
add :grid, :string
add :lat, :float
add :lon, :float
add :power_watts, :float
add :height_m, :float
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:beacons, [:user_id])
end
end

View file

@ -0,0 +1,132 @@
defmodule Microwaveprop.BeaconsTest do
use Microwaveprop.DataCase
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@invalid_attrs %{
frequency_mhz: nil,
callsign: nil,
grid: nil,
lat: nil,
lon: nil,
power_watts: nil,
height_m: nil
}
describe "list_beacons/0" do
test "returns all beacons regardless of creator" 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)
end
end
describe "get_beacon!/1" do
test "returns the beacon with given id" do
user = user_fixture()
beacon = beacon_fixture(user)
assert Beacons.get_beacon!(beacon.id).id == beacon.id
end
end
describe "create_beacon/2" do
test "with valid data creates a beacon and records the creator" do
user = user_fixture()
attrs = valid_beacon_attrs()
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(user, attrs)
assert beacon.frequency_mhz == attrs.frequency_mhz
assert beacon.callsign == "W5HN"
assert beacon.lat == attrs.lat
assert beacon.lon == attrs.lon
assert beacon.power_watts == attrs.power_watts
assert beacon.height_m == attrs.height_m
assert beacon.user_id == user.id
end
test "derives grid from lat/lon when grid is omitted" do
user = user_fixture()
# DFW is EM12
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(lat: 32.897, lon: -97.038))
assert String.starts_with?(beacon.grid, "EM12")
end
test "keeps an explicitly provided valid grid" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(grid: "EM13"))
assert beacon.grid == "EM13"
end
test "rejects an invalid grid" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(grid: "ZZ99"))
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid
end
test "upcases the callsign" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))
assert beacon.callsign == "W5HN"
end
test "rejects non-positive frequency" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(frequency_mhz: 0))
assert "must be greater than 0" in errors_on(changeset).frequency_mhz
end
test "with invalid data returns error changeset" do
user = user_fixture()
assert {:error, %Ecto.Changeset{}} = Beacons.create_beacon(user, @invalid_attrs)
end
end
describe "update_beacon/2" do
test "with valid data updates the beacon" do
user = user_fixture()
beacon = beacon_fixture(user)
update_attrs = %{frequency_mhz: 24_192.1, power_watts: 5.0}
assert {:ok, %Beacon{} = beacon} = Beacons.update_beacon(beacon, update_attrs)
assert beacon.frequency_mhz == 24_192.1
assert beacon.power_watts == 5.0
end
test "with invalid data returns error changeset" do
user = user_fixture()
beacon = beacon_fixture(user)
assert {:error, %Ecto.Changeset{}} = Beacons.update_beacon(beacon, @invalid_attrs)
assert Beacons.get_beacon!(beacon.id).callsign == beacon.callsign
end
end
describe "delete_beacon/1" do
test "deletes the beacon" do
user = user_fixture()
beacon = beacon_fixture(user)
assert {:ok, %Beacon{}} = Beacons.delete_beacon(beacon)
assert_raise Ecto.NoResultsError, fn -> Beacons.get_beacon!(beacon.id) end
end
end
describe "change_beacon/1" do
test "returns a beacon changeset" do
user = user_fixture()
beacon = beacon_fixture(user)
assert %Ecto.Changeset{} = Beacons.change_beacon(beacon)
end
end
end

View file

@ -104,4 +104,32 @@ defmodule Microwaveprop.Radio.MaidenheadTest do
assert_in_delta lon, 179.0, 0.01
end
end
describe "from_latlon/3" do
test "round-trips 4-char grid FN31" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("FN31")
assert Maidenhead.from_latlon(lat, lon, 4) == "FN31"
end
test "round-trips 6-char grid FN31pr" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("FN31pr")
assert Maidenhead.from_latlon(lat, lon, 6) == "FN31pr"
end
test "defaults to precision 6" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp")
assert Maidenhead.from_latlon(lat, lon) == "EM12kp"
end
test "round-trips 8-char grid EM12kp37" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp37")
assert Maidenhead.from_latlon(lat, lon, 8) == "EM12kp37"
end
test "handles North Texas coordinates" do
# DFW area is approximately EM12/EM13
grid = Maidenhead.from_latlon(32.897, -97.038, 6)
assert String.starts_with?(grid, "EM12")
end
end
end

View file

@ -0,0 +1,94 @@
defmodule MicrowavepropWeb.BeaconLiveTest do
use MicrowavepropWeb.ConnCase
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Accounts
defp promote_to_admin(user) do
{:ok, user} = Accounts.admin_update_user(user, %{is_admin: true})
user
end
defp admin_user_fixture do
promote_to_admin(user_fixture())
end
describe "Index (public)" do
test "lists beacons without logging in", %{conn: conn} do
beacon = 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
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "New Beacon"
end
test "admin sees the New Beacon button", %{conn: conn} do
conn = log_in_user(conn, admin_user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "New Beacon"
end
end
describe "Show (public)" do
test "renders beacon details without logging in", %{conn: conn} do
beacon = beacon_fixture(user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ beacon.callsign
assert html =~ "#{beacon.frequency_mhz}"
end
end
describe "Form (admin only)" do
test "redirects non-admin away from /beacons/new", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/new")
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())
{:ok, form_live, _html} = live(conn, ~p"/beacons/new")
attrs = valid_beacon_attrs()
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
end
test "admin edits a beacon", %{conn: conn} do
admin = admin_user_fixture()
beacon = beacon_fixture(admin)
conn = log_in_user(conn, admin)
{:ok, form_live, _} = live(conn, ~p"/beacons/#{beacon}/edit")
assert {:ok, _view, html} =
form_live
|> form("#beacon-form", beacon: %{power_watts: 25.0})
|> render_submit()
|> follow_redirect(conn, ~p"/beacons")
assert html =~ "Beacon updated"
end
end
end

View file

@ -0,0 +1,71 @@
defmodule MicrowavepropWeb.UserManagementLiveTest do
use MicrowavepropWeb.ConnCase
import Microwaveprop.AccountsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Accounts
defp admin_user_fixture do
user = user_fixture()
{:ok, user} = Accounts.admin_update_user(user, %{is_admin: true})
user
end
describe "Index" do
test "redirects non-admin to /", %{conn: conn} do
conn = log_in_user(conn, user_fixture())
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/users")
end
test "redirects anonymous user to log in", %{conn: conn} do
assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/users")
end
test "admin sees the user list", %{conn: conn} do
admin = admin_user_fixture()
other = user_fixture(callsign: "W5TX")
conn = log_in_user(conn, admin)
{:ok, _view, html} = live(conn, ~p"/users")
assert html =~ admin.callsign
assert html =~ other.callsign
end
test "admin can delete another user", %{conn: conn} do
admin = admin_user_fixture()
target = user_fixture(callsign: "W5TX")
conn = log_in_user(conn, admin)
{:ok, view, _} = live(conn, ~p"/users")
view |> element("tr##{"users-" <> target.id} a", "Delete") |> render_click()
refute Accounts.get_user_by_email(target.email)
end
end
describe "Edit" do
test "admin can promote a user to admin", %{conn: conn} do
admin = admin_user_fixture()
target = user_fixture(callsign: "W5TX")
conn = log_in_user(conn, admin)
{:ok, form_live, _} = live(conn, ~p"/users/#{target.id}/edit")
assert {:ok, _view, _html} =
form_live
|> form("#user-form",
user: %{
callsign: target.callsign,
name: target.name,
email: target.email,
is_admin: true
}
)
|> render_submit()
|> follow_redirect(conn, ~p"/users")
assert Accounts.get_user!(target.id).is_admin
end
end
end

View file

@ -0,0 +1,23 @@
defmodule Microwaveprop.BeaconsFixtures do
@moduledoc """
Test helpers for creating beacon records.
"""
alias Microwaveprop.Beacons
def valid_beacon_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
frequency_mhz: 10_368.1,
callsign: "W5HN",
lat: 32.897,
lon: -97.038,
power_watts: 10.0,
height_m: 30.0
})
end
def beacon_fixture(user, attrs \\ %{}) do
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(attrs))
beacon
end
end