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:
parent
5e4a306fb2
commit
ee9275e0b9
19 changed files with 1071 additions and 0 deletions
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
80
lib/microwaveprop/beacons.ex
Normal file
80
lib/microwaveprop/beacons.ex
Normal 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
|
||||
86
lib/microwaveprop/beacons/beacon.ex
Normal file
86
lib/microwaveprop/beacons/beacon.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
121
lib/microwaveprop_web/live/beacon_live/form.ex
Normal file
121
lib/microwaveprop_web/live/beacon_live/form.ex
Normal 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
|
||||
81
lib/microwaveprop_web/live/beacon_live/index.ex
Normal file
81
lib/microwaveprop_web/live/beacon_live/index.ex
Normal 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
|
||||
70
lib/microwaveprop_web/live/beacon_live/show.ex
Normal file
70
lib/microwaveprop_web/live/beacon_live/show.ex
Normal 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 · {@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
|
||||
61
lib/microwaveprop_web/live/user_management_live/edit.ex
Normal file
61
lib/microwaveprop_web/live/user_management_live/edit.ex
Normal 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} · {@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
|
||||
70
lib/microwaveprop_web/live/user_management_live/index.ex
Normal file
70
lib/microwaveprop_web/live/user_management_live/index.ex
Normal 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">—</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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
21
priv/repo/migrations/20260408165341_create_beacons.exs
Normal file
21
priv/repo/migrations/20260408165341_create_beacons.exs
Normal 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
|
||||
132
test/microwaveprop/beacons_test.exs
Normal file
132
test/microwaveprop/beacons_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
94
test/microwaveprop_web/live/beacon_live_test.exs
Normal file
94
test/microwaveprop_web/live/beacon_live_test.exs
Normal 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
|
||||
71
test/microwaveprop_web/live/user_management_live_test.exs
Normal file
71
test/microwaveprop_web/live/user_management_live_test.exs
Normal 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
|
||||
23
test/support/fixtures/beacons_fixtures.ex
Normal file
23
test/support/fixtures/beacons_fixtures.ex
Normal 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
|
||||
Loading…
Add table
Reference in a new issue