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:
parent
4323f81b97
commit
ba0f1161a7
11 changed files with 93 additions and 32 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue