Add contact edit approval system with admin review queue
Registered users can suggest edits to any contact's core fields (callsigns, grids, band, mode, timestamp). Edits enter an admin approval queue with field-by-field diff view. On approve, changes are applied and enrichment re-enqueued if grids/band changed. Users receive email notification on approve or reject. Also updates dependabot.yml for mix ecosystem.
This commit is contained in:
parent
0fc4f37e86
commit
a66d3094ca
17 changed files with 1189 additions and 16 deletions
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
|
|
@ -1,12 +1,6 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
- package-ecosystem: "mix"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
|
|
|
|||
64
docs/plans/2026-04-11-contact-edit-approval-design.md
Normal file
64
docs/plans/2026-04-11-contact-edit-approval-design.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Contact Edit Approval System — Design
|
||||
|
||||
## Goal
|
||||
|
||||
Allow registered users to suggest corrections to any contact's core fields. Edits enter an admin approval queue with field-by-field diff view. Users receive email notification when their edit is approved or rejected.
|
||||
|
||||
## Data Model
|
||||
|
||||
New `contact_edits` table:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| id | binary_id | PK |
|
||||
| contact_id | references contacts | Target contact |
|
||||
| user_id | references users | Submitter |
|
||||
| proposed_changes | :map (JSONB) | Only changed fields, e.g. `%{"grid1" => "EM13kk"}` |
|
||||
| status | Ecto.Enum (:pending, :approved, :rejected) | Review state |
|
||||
| admin_note | :string | Optional admin explanation |
|
||||
| reviewed_by_id | references users | Admin who acted |
|
||||
| reviewed_at | :utc_datetime | When reviewed |
|
||||
| timestamps | | inserted_at = submission time |
|
||||
|
||||
Schema: `ContactEdit` in `lib/microwaveprop/radio/contact_edit.ex`.
|
||||
|
||||
## Editable Fields
|
||||
|
||||
station1, station2, grid1, grid2, band, mode, qso_timestamp.
|
||||
|
||||
Not editable: pos1/pos2, distance_km, enrichment statuses (computed fields).
|
||||
|
||||
## User Flow
|
||||
|
||||
- Contact detail page (`/contacts/:id`): logged-in users see "Suggest Edit" button.
|
||||
- Clicking opens inline form pre-filled with current values.
|
||||
- On submit: backend diffs against current contact, stores only changed fields.
|
||||
- Validation: same rules as submission_changeset (callsign format, grid format, valid band/mode).
|
||||
- Reject if nothing changed.
|
||||
- Flash confirmation on success. Badge shows "pending edit" if user has one.
|
||||
|
||||
## Admin Flow
|
||||
|
||||
- `/admin/contact-edits`: pending edits, newest first.
|
||||
- Columns: contact link, submitter callsign, submitted at, fields changed.
|
||||
- Review view: field-by-field diff table (current vs proposed), only changed fields.
|
||||
- Optional note textarea, Approve/Reject buttons.
|
||||
- Approve: update contact fields, re-enqueue enrichment if grid1/grid2/band changed.
|
||||
- Reject: no contact changes.
|
||||
- Nav badge shows pending count.
|
||||
|
||||
## Multiple Edits
|
||||
|
||||
Independent queue — multiple edits per contact allowed. Admin reviews each separately.
|
||||
|
||||
## Email Notifications
|
||||
|
||||
Plain text via Swoosh (existing mailer). Sent on approve or reject. Includes:
|
||||
- Contact identifier (station1 <-> station2, band, date)
|
||||
- What was proposed
|
||||
- Decision (approved/rejected)
|
||||
- Admin note if provided
|
||||
|
||||
## Re-enrichment
|
||||
|
||||
On approve, if any of grid1/grid2/band changed: recalculate pos1/pos2 from grids, clear distance_km (will be recomputed), re-enqueue weather/HRRR/terrain/IEMRE jobs.
|
||||
|
|
@ -68,7 +68,9 @@ defmodule Microwaveprop.Propagation.RainScatter do
|
|||
"""
|
||||
def classify(scatter_cells) do
|
||||
case scatter_cells do
|
||||
[] -> :none
|
||||
[] ->
|
||||
:none
|
||||
|
||||
[best | _] ->
|
||||
cond do
|
||||
best.scatter_db >= -10 -> :excellent
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule Microwaveprop.Radio do
|
|||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.ContactEdit
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
|
|
@ -354,4 +355,215 @@ defmodule Microwaveprop.Radio do
|
|||
{:error, %{changeset | action: :insert}}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Contact Edits ───────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Create a proposed edit for a contact. Only stores fields that actually
|
||||
differ from the contact's current values. Normalizes callsigns and grids
|
||||
to uppercase.
|
||||
"""
|
||||
def create_contact_edit(%Contact{} = contact, user, proposed_changes) when is_map(proposed_changes) do
|
||||
# Normalize and diff against current values
|
||||
normalized = normalize_proposed(proposed_changes)
|
||||
diffed = diff_against_contact(contact, normalized)
|
||||
|
||||
attrs = %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: diffed
|
||||
}
|
||||
|
||||
%ContactEdit{}
|
||||
|> ContactEdit.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp normalize_proposed(changes) do
|
||||
changes
|
||||
|> normalize_string_field("station1")
|
||||
|> normalize_string_field("station2")
|
||||
|> normalize_string_field("grid1")
|
||||
|> normalize_string_field("grid2")
|
||||
|> normalize_string_field("mode")
|
||||
end
|
||||
|
||||
defp normalize_string_field(map, key) do
|
||||
case Map.get(map, key) do
|
||||
nil -> map
|
||||
val when is_binary(val) -> Map.put(map, key, val |> String.trim() |> String.upcase())
|
||||
val -> Map.put(map, key, val)
|
||||
end
|
||||
end
|
||||
|
||||
defp diff_against_contact(contact, proposed) do
|
||||
Enum.reduce(proposed, %{}, fn {key, new_val}, acc ->
|
||||
current = current_value(contact, key)
|
||||
|
||||
if values_equal?(current, new_val) do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, key, new_val)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp current_value(contact, "station1"), do: contact.station1
|
||||
defp current_value(contact, "station2"), do: contact.station2
|
||||
defp current_value(contact, "grid1"), do: contact.grid1
|
||||
defp current_value(contact, "grid2"), do: contact.grid2
|
||||
defp current_value(contact, "mode"), do: contact.mode
|
||||
|
||||
defp current_value(contact, "band") do
|
||||
if contact.band, do: Decimal.to_integer(contact.band)
|
||||
end
|
||||
|
||||
defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp
|
||||
defp current_value(_contact, _key), do: nil
|
||||
|
||||
defp values_equal?(a, b) when is_binary(a) and is_binary(b) do
|
||||
String.upcase(a) == String.upcase(b)
|
||||
end
|
||||
|
||||
defp values_equal?(a, b) when is_integer(a), do: a == to_integer(b)
|
||||
defp values_equal?(a, b) when is_integer(b), do: to_integer(a) == b
|
||||
defp values_equal?(a, b), do: a == b
|
||||
|
||||
defp to_integer(v) when is_integer(v), do: v
|
||||
defp to_integer(v) when is_binary(v), do: String.to_integer(v)
|
||||
defp to_integer(%Decimal{} = v), do: Decimal.to_integer(v)
|
||||
defp to_integer(v), do: v
|
||||
|
||||
def list_pending_edits do
|
||||
ContactEdit
|
||||
|> where([e], e.status == :pending)
|
||||
|> order_by([e], desc: e.inserted_at)
|
||||
|> preload([:user, :contact])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_contact_edits(contact_id) do
|
||||
ContactEdit
|
||||
|> where([e], e.contact_id == ^contact_id)
|
||||
|> order_by([e], desc: e.inserted_at)
|
||||
|> preload([:user, :reviewed_by])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def pending_edit_count do
|
||||
ContactEdit
|
||||
|> where([e], e.status == :pending)
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
def pending_edit_for_user(contact_id, user_id) do
|
||||
ContactEdit
|
||||
|> where([e], e.contact_id == ^contact_id and e.user_id == ^user_id and e.status == :pending)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_contact_edit!(id) do
|
||||
ContactEdit
|
||||
|> preload([:user, :contact, :reviewed_by])
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do
|
||||
Repo.transaction(fn ->
|
||||
# Mark edit as approved
|
||||
{:ok, approved} =
|
||||
edit
|
||||
|> ContactEdit.review_changeset(%{
|
||||
status: :approved,
|
||||
admin_note: note,
|
||||
reviewed_by_id: admin.id,
|
||||
reviewed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
# Apply changes to contact
|
||||
contact = Repo.get!(Contact, edit.contact_id)
|
||||
apply_edit_to_contact(contact, edit.proposed_changes)
|
||||
|
||||
Repo.preload(approved, [:user, :contact, :reviewed_by])
|
||||
end)
|
||||
end
|
||||
|
||||
def reject_edit(%ContactEdit{status: :pending} = edit, admin, note) do
|
||||
edit
|
||||
|> ContactEdit.review_changeset(%{
|
||||
status: :rejected,
|
||||
admin_note: note,
|
||||
reviewed_by_id: admin.id,
|
||||
reviewed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp apply_edit_to_contact(contact, proposed_changes) do
|
||||
changes = build_contact_changes(contact, proposed_changes)
|
||||
|
||||
grids_or_band_changed =
|
||||
Map.has_key?(proposed_changes, "grid1") or
|
||||
Map.has_key?(proposed_changes, "grid2") or
|
||||
Map.has_key?(proposed_changes, "band")
|
||||
|
||||
changes =
|
||||
if grids_or_band_changed do
|
||||
# Recompute positions from grids
|
||||
new_grid1 = Map.get(proposed_changes, "grid1", contact.grid1)
|
||||
new_grid2 = Map.get(proposed_changes, "grid2", contact.grid2)
|
||||
|
||||
pos1 = resolve_grid_position(new_grid1) || contact.pos1
|
||||
pos2 = resolve_grid_position(new_grid2) || contact.pos2
|
||||
|
||||
distance =
|
||||
if pos1 && pos2 do
|
||||
lon1 = pos1["lon"] || pos1["lng"]
|
||||
lon2 = pos2["lon"] || pos2["lng"]
|
||||
pos1["lat"] |> haversine_km(lon1, pos2["lat"], lon2) |> round() |> Decimal.new()
|
||||
end
|
||||
|
||||
changes
|
||||
|> Map.put(:pos1, pos1)
|
||||
|> Map.put(:pos2, pos2)
|
||||
|> Map.put(:distance_km, distance)
|
||||
|> Map.put(:hrrr_status, :pending)
|
||||
|> Map.put(:weather_status, :pending)
|
||||
|> Map.put(:terrain_status, :pending)
|
||||
|> Map.put(:iemre_status, :pending)
|
||||
else
|
||||
changes
|
||||
end
|
||||
|
||||
contact
|
||||
|> Ecto.Changeset.change(changes)
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
defp build_contact_changes(_contact, proposed) do
|
||||
Enum.reduce(proposed, %{}, fn
|
||||
{"band", val}, acc ->
|
||||
Map.put(acc, :band, Decimal.new(to_string(val)))
|
||||
|
||||
{"qso_timestamp", val}, acc when is_binary(val) ->
|
||||
{:ok, dt, _} = DateTime.from_iso8601(val)
|
||||
Map.put(acc, :qso_timestamp, dt)
|
||||
|
||||
{"qso_timestamp", %DateTime{} = dt}, acc ->
|
||||
Map.put(acc, :qso_timestamp, dt)
|
||||
|
||||
{key, val}, acc ->
|
||||
Map.put(acc, String.to_existing_atom(key), val)
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_grid_position(nil), do: nil
|
||||
|
||||
defp resolve_grid_position(grid) do
|
||||
case Maidenhead.to_latlon(grid) do
|
||||
{:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
140
lib/microwaveprop/radio/contact_edit.ex
Normal file
140
lib/microwaveprop/radio/contact_edit.ex
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
defmodule Microwaveprop.Radio.ContactEdit do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@allowed_modes ~w(CW SSB FM FT8 FT4)
|
||||
@allowed_bands ~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000)
|
||||
|
||||
schema "contact_edits" do
|
||||
belongs_to :contact, Contact
|
||||
belongs_to :user, User
|
||||
belongs_to :reviewed_by, User
|
||||
|
||||
field :proposed_changes, :map
|
||||
field :status, Ecto.Enum, values: [:pending, :approved, :rejected], default: :pending
|
||||
field :admin_note, :string
|
||||
field :reviewed_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
||||
|
||||
def changeset(edit, attrs) do
|
||||
edit
|
||||
|> cast(attrs, [:contact_id, :user_id, :proposed_changes])
|
||||
|> validate_required([:contact_id, :user_id, :proposed_changes])
|
||||
|> validate_proposed_changes()
|
||||
|> foreign_key_constraint(:contact_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
end
|
||||
|
||||
def review_changeset(edit, attrs) do
|
||||
edit
|
||||
|> cast(attrs, [:status, :admin_note, :reviewed_by_id, :reviewed_at])
|
||||
|> validate_required([:status, :reviewed_by_id, :reviewed_at])
|
||||
|> validate_inclusion(:status, [:approved, :rejected])
|
||||
end
|
||||
|
||||
defp validate_proposed_changes(changeset) do
|
||||
case get_field(changeset, :proposed_changes) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
changes when changes == %{} ->
|
||||
add_error(changeset, :proposed_changes, "must contain at least one change")
|
||||
|
||||
changes when is_map(changes) ->
|
||||
keys = Map.keys(changes)
|
||||
invalid_keys = Enum.reject(keys, &(&1 in @editable_fields))
|
||||
|
||||
changeset =
|
||||
if invalid_keys == [] do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :proposed_changes, "contains invalid fields: #{Enum.join(invalid_keys, ", ")}")
|
||||
end
|
||||
|
||||
Enum.reduce(keys, changeset, &validate_field_value/2)
|
||||
|
||||
_ ->
|
||||
add_error(changeset, :proposed_changes, "must be a map")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_field_value("station1", cs), do: validate_callsign_value(cs, "station1")
|
||||
defp validate_field_value("station2", cs), do: validate_callsign_value(cs, "station2")
|
||||
defp validate_field_value("grid1", cs), do: validate_grid_value(cs, "grid1")
|
||||
defp validate_field_value("grid2", cs), do: validate_grid_value(cs, "grid2")
|
||||
|
||||
defp validate_field_value("band", changeset) do
|
||||
val = get_field(changeset, :proposed_changes)["band"]
|
||||
|
||||
if to_string(val) in @allowed_bands do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :proposed_changes, "band is not a valid frequency")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_field_value("mode", changeset) do
|
||||
val = get_field(changeset, :proposed_changes)["mode"]
|
||||
|
||||
if val in @allowed_modes do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :proposed_changes, "mode must be one of: #{Enum.join(@allowed_modes, ", ")}")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_field_value("qso_timestamp", changeset) do
|
||||
val = get_field(changeset, :proposed_changes)["qso_timestamp"]
|
||||
|
||||
case val do
|
||||
%DateTime{} ->
|
||||
changeset
|
||||
|
||||
s when is_binary(s) ->
|
||||
case DateTime.from_iso8601(s) do
|
||||
{:ok, _, _} -> changeset
|
||||
_ -> add_error(changeset, :proposed_changes, "qso_timestamp is not a valid datetime")
|
||||
end
|
||||
|
||||
_ ->
|
||||
add_error(changeset, :proposed_changes, "qso_timestamp is not a valid datetime")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_field_value(_, changeset), do: changeset
|
||||
|
||||
defp validate_callsign_value(changeset, field) do
|
||||
val = get_field(changeset, :proposed_changes)[field] || ""
|
||||
val = val |> String.trim() |> String.upcase()
|
||||
|
||||
if Regex.match?(~r/^[A-Z0-9\/]+$/, val) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :proposed_changes, "#{field} must contain only letters, digits, and /")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_grid_value(changeset, field) do
|
||||
val = get_field(changeset, :proposed_changes)[field] || ""
|
||||
val = val |> String.trim() |> String.upcase()
|
||||
|
||||
if Maidenhead.valid?(val) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :proposed_changes, "#{field} is not a valid Maidenhead grid square")
|
||||
end
|
||||
end
|
||||
end
|
||||
98
lib/microwaveprop/radio/edit_notifier.ex
Normal file
98
lib/microwaveprop/radio/edit_notifier.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule Microwaveprop.Radio.EditNotifier do
|
||||
@moduledoc false
|
||||
import Swoosh.Email
|
||||
|
||||
alias Microwaveprop.Mailer
|
||||
|
||||
@default_from {"NTMS Propagation", "noreply@mcintire.me"}
|
||||
|
||||
def deliver_edit_approved(edit) do
|
||||
edit = Microwaveprop.Repo.preload(edit, [:user, :contact])
|
||||
contact = edit.contact
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Hi #{edit.user.callsign},
|
||||
|
||||
Your suggested edit to the contact #{contact.station1} <-> #{contact.station2}
|
||||
(#{format_band(contact.band)}, #{format_ts(contact.qso_timestamp)}) has been APPROVED
|
||||
and applied.
|
||||
|
||||
Changes applied:
|
||||
#{format_changes(edit.proposed_changes)}
|
||||
#{format_note(edit.admin_note)}
|
||||
View the contact: #{url()}/contacts/#{contact.id}
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(edit.user.email, "Your contact edit was approved", body)
|
||||
end
|
||||
|
||||
def deliver_edit_rejected(edit) do
|
||||
edit = Microwaveprop.Repo.preload(edit, [:user, :contact])
|
||||
contact = edit.contact
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Hi #{edit.user.callsign},
|
||||
|
||||
Your suggested edit to the contact #{contact.station1} <-> #{contact.station2}
|
||||
(#{format_band(contact.band)}, #{format_ts(contact.qso_timestamp)}) has been REJECTED.
|
||||
|
||||
Proposed changes:
|
||||
#{format_changes(edit.proposed_changes)}
|
||||
#{format_note(edit.admin_note)}
|
||||
View the contact: #{url()}/contacts/#{contact.id}
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(edit.user.email, "Your contact edit was rejected", body)
|
||||
end
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from(from_address())
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
end
|
||||
|
||||
defp from_address do
|
||||
case System.get_env("EMAIL_FROM") do
|
||||
nil -> @default_from
|
||||
"" -> @default_from
|
||||
addr -> {"NTMS Propagation", addr}
|
||||
end
|
||||
end
|
||||
|
||||
defp url do
|
||||
MicrowavepropWeb.Endpoint.url()
|
||||
end
|
||||
|
||||
defp format_band(nil), do: "?"
|
||||
|
||||
defp format_band(band) do
|
||||
mhz = Decimal.to_integer(band)
|
||||
if mhz >= 1000, do: "#{div(mhz, 1000)} GHz", else: "#{mhz} MHz"
|
||||
end
|
||||
|
||||
defp format_ts(nil), do: "?"
|
||||
defp format_ts(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC")
|
||||
|
||||
defp format_changes(changes) do
|
||||
Enum.map_join(changes, "\n", fn {field, value} -> " - #{field}: #{value}" end)
|
||||
end
|
||||
|
||||
defp format_note(nil), do: ""
|
||||
defp format_note(""), do: ""
|
||||
defp format_note(note), do: "\nAdmin note: #{note}\n"
|
||||
end
|
||||
|
|
@ -221,7 +221,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
|
|||
defp min_m_gradient(%{heights_m: heights, temp_k: temps, spfh: spfhs, pressure_pa: pressures}) do
|
||||
# Compute modified refractivity M at each level, find minimum dM/dh
|
||||
ms =
|
||||
Enum.zip([heights, temps, spfhs, pressures])
|
||||
[heights, temps, spfhs, pressures]
|
||||
|> Enum.zip()
|
||||
|> Enum.map(fn {h, t, q, p} ->
|
||||
# N = 77.6 * P/T + 3.73e5 * e/T^2, where e = q*P/(0.622 + 0.378*q)
|
||||
q = max(q || 0.0, 1.0e-8)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
>
|
||||
Users
|
||||
</.link>
|
||||
<.link
|
||||
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
|
||||
navigate="/admin/contact-edits"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
Edits
|
||||
</.link>
|
||||
<.link
|
||||
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
|
||||
href="/admin/oban"
|
||||
|
|
|
|||
240
lib/microwaveprop_web/live/admin/contact_edit_live.ex
Normal file
240
lib/microwaveprop_web/live/admin/contact_edit_live.ex
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
||||
@moduledoc false
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.EditNotifier
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
edits = Radio.list_pending_edits()
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Review Contact Edits",
|
||||
edits: edits,
|
||||
reviewing: nil,
|
||||
admin_note: ""
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("review", %{"id" => id}, socket) do
|
||||
edit = Radio.get_contact_edit!(id)
|
||||
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
|
||||
end
|
||||
|
||||
def handle_event("cancel_review", _params, socket) do
|
||||
{:noreply, assign(socket, reviewing: nil, admin_note: "")}
|
||||
end
|
||||
|
||||
def handle_event("update_note", %{"note" => note}, socket) do
|
||||
{:noreply, assign(socket, admin_note: note)}
|
||||
end
|
||||
|
||||
def handle_event("approve", _params, socket) do
|
||||
edit = socket.assigns.reviewing
|
||||
admin = socket.assigns.current_scope.user
|
||||
note = normalize_note(socket.assigns.admin_note)
|
||||
|
||||
case Radio.approve_edit(edit, admin, note) do
|
||||
{:ok, approved} ->
|
||||
EditNotifier.deliver_edit_approved(approved)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(reviewing: nil, admin_note: "", edits: Radio.list_pending_edits())
|
||||
|> put_flash(:info, "Edit approved and applied.")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to approve edit.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reject", _params, socket) do
|
||||
edit = socket.assigns.reviewing
|
||||
admin = socket.assigns.current_scope.user
|
||||
note = normalize_note(socket.assigns.admin_note)
|
||||
|
||||
case Radio.reject_edit(edit, admin, note) do
|
||||
{:ok, rejected} ->
|
||||
EditNotifier.deliver_edit_rejected(rejected)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(reviewing: nil, admin_note: "", edits: Radio.list_pending_edits())
|
||||
|> put_flash(:info, "Edit rejected.")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to reject edit.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_note(""), do: nil
|
||||
defp normalize_note(note), do: String.trim(note)
|
||||
|
||||
defp format_band(nil), do: "?"
|
||||
|
||||
defp format_band(band) do
|
||||
mhz = Decimal.to_integer(band)
|
||||
if mhz >= 1000, do: "#{div(mhz, 1000)} GHz", else: "#{mhz} MHz"
|
||||
end
|
||||
|
||||
defp format_ts(nil), do: "?"
|
||||
defp format_ts(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC")
|
||||
|
||||
defp changed_fields_summary(proposed_changes) do
|
||||
proposed_changes
|
||||
|> Map.keys()
|
||||
|> Enum.sort()
|
||||
|> Enum.join(", ")
|
||||
end
|
||||
|
||||
defp field_label("station1"), do: "Station 1"
|
||||
defp field_label("station2"), do: "Station 2"
|
||||
defp field_label("grid1"), do: "Grid 1"
|
||||
defp field_label("grid2"), do: "Grid 2"
|
||||
defp field_label("band"), do: "Band"
|
||||
defp field_label("mode"), do: "Mode"
|
||||
defp field_label("qso_timestamp"), do: "Timestamp"
|
||||
defp field_label(other), do: other
|
||||
|
||||
defp current_value(contact, "station1"), do: contact.station1
|
||||
defp current_value(contact, "station2"), do: contact.station2
|
||||
defp current_value(contact, "grid1"), do: contact.grid1
|
||||
defp current_value(contact, "grid2"), do: contact.grid2
|
||||
defp current_value(contact, "band"), do: if(contact.band, do: Decimal.to_string(contact.band))
|
||||
defp current_value(contact, "mode"), do: contact.mode
|
||||
|
||||
defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp))
|
||||
|
||||
defp current_value(_contact, _), do: "?"
|
||||
|
||||
defp format_proposed("band", val), do: to_string(val)
|
||||
defp format_proposed("qso_timestamp", val) when is_binary(val), do: val
|
||||
defp format_proposed(_field, val), do: to_string(val)
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
Review Contact Edits
|
||||
<:subtitle>{length(@edits)} pending</:subtitle>
|
||||
<:actions>
|
||||
<.link navigate={~p"/admin/backfill"} class="btn btn-sm btn-ghost">
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Admin
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%= if @reviewing do %>
|
||||
<div class="bg-base-200 rounded-box p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg">
|
||||
Review Edit from {@reviewing.user.callsign}
|
||||
</h3>
|
||||
<button phx-click="cancel_review" class="btn btn-ghost btn-sm">
|
||||
<.icon name="hero-x-mark" class="w-4 h-4" /> Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-sm opacity-70 mb-4">
|
||||
Contact:
|
||||
<.link navigate={~p"/contacts/#{@reviewing.contact.id}"} class="link link-primary">
|
||||
{@reviewing.contact.station1} / {@reviewing.contact.station2}
|
||||
</.link>
|
||||
· {format_band(@reviewing.contact.band)} · {format_ts(
|
||||
@reviewing.contact.qso_timestamp
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-box border border-base-300 mb-4">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Current</th>
|
||||
<th>Proposed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for {field, proposed_val} <- Enum.sort(@reviewing.proposed_changes) do %>
|
||||
<tr>
|
||||
<td class="font-semibold">{field_label(field)}</td>
|
||||
<td class="opacity-70">{current_value(@reviewing.contact, field)}</td>
|
||||
<td class="text-primary font-semibold">{format_proposed(field, proposed_val)}</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="label text-sm">Note (optional)</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="2"
|
||||
placeholder="Reason for decision..."
|
||||
phx-change="update_note"
|
||||
name="note"
|
||||
>{@admin_note}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button phx-click="approve" class="btn btn-success btn-sm">
|
||||
<.icon name="hero-check" class="w-4 h-4" /> Approve
|
||||
</button>
|
||||
<button phx-click="reject" class="btn btn-error btn-sm">
|
||||
<.icon name="hero-x-mark" class="w-4 h-4" /> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @edits == [] do %>
|
||||
<div class="text-center py-12 opacity-50">
|
||||
<.icon name="hero-check-circle" class="w-12 h-12 mx-auto mb-2" />
|
||||
<p>No pending edits to review.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<th>Submitted By</th>
|
||||
<th>Fields Changed</th>
|
||||
<th>Submitted</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for edit <- @edits do %>
|
||||
<tr>
|
||||
<td>
|
||||
<.link navigate={~p"/contacts/#{edit.contact.id}"} class="link link-primary">
|
||||
{edit.contact.station1} / {edit.contact.station2}
|
||||
</.link>
|
||||
<div class="text-xs opacity-60">
|
||||
{format_band(edit.contact.band)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{edit.user.callsign}</td>
|
||||
<td class="text-sm">{changed_fields_summary(edit.proposed_changes)}</td>
|
||||
<td class="text-sm">{format_ts(edit.inserted_at)}</td>
|
||||
<td>
|
||||
<button phx-click="review" phx-value-id={edit.id} class="btn btn-primary btn-xs">
|
||||
Review
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -120,7 +120,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
|||
v -> v
|
||||
end)
|
||||
|> Map.update("height_ft", nil, fn
|
||||
v when is_binary(v) -> v |> String.replace(~r/\.0*$/, "")
|
||||
v when is_binary(v) -> String.replace(v, ~r/\.0*$/, "")
|
||||
v -> v
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
rows={@streams.beacons}
|
||||
row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end}
|
||||
>
|
||||
<:col :let={{_id, beacon}} label="Frequency (MHz)">{Beacon.format_freq(beacon.frequency_mhz)}</:col>
|
||||
<:col :let={{_id, beacon}} label="Frequency (MHz)">
|
||||
{Beacon.format_freq(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">{format_coord(beacon.lat)}</:col>
|
||||
|
|
@ -65,7 +67,9 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
rows={@streams.pending}
|
||||
row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end}
|
||||
>
|
||||
<:col :let={{_id, beacon}} label="Frequency (MHz)">{Beacon.format_freq(beacon.frequency_mhz)}</:col>
|
||||
<:col :let={{_id, beacon}} label="Frequency (MHz)">
|
||||
{Beacon.format_freq(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)">{Beacon.format_mw(beacon.power_mw)}</:col>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
@enqueue_subnet {172, 56, 0, 0}
|
||||
@enqueue_mask 13
|
||||
|
||||
@band_options BandConfig.band_options()
|
||||
@mode_options ~w(CW SSB FM FT8 FT4)
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, session, socket) do
|
||||
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
|
||||
|
|
@ -70,10 +73,28 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
sounding_sort_by: "station_name",
|
||||
sounding_sort_order: "asc",
|
||||
queue_counts: fetch_queue_counts(),
|
||||
expanded_soundings: MapSet.new()
|
||||
expanded_soundings: MapSet.new(),
|
||||
editing: false,
|
||||
edit_form: nil,
|
||||
pending_edit: load_pending_edit(contact, socket),
|
||||
band_options: @band_options,
|
||||
mode_options: @mode_options
|
||||
)}
|
||||
end
|
||||
|
||||
defp load_pending_edit(contact, socket) do
|
||||
case socket.assigns do
|
||||
%{current_scope: %{user: %{id: user_id}}} ->
|
||||
Radio.pending_edit_for_user(contact.id, user_id)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
|
||||
defp current_user(_), do: nil
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
|
||||
{sort_by, sort_order} =
|
||||
|
|
@ -127,6 +148,58 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_edit", _params, socket) do
|
||||
if socket.assigns.editing do
|
||||
{:noreply, assign(socket, editing: false, edit_form: nil)}
|
||||
else
|
||||
contact = socket.assigns.contact
|
||||
|
||||
form_data = %{
|
||||
"station1" => contact.station1,
|
||||
"station2" => contact.station2,
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""),
|
||||
"mode" => contact.mode,
|
||||
"qso_timestamp" =>
|
||||
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%dT%H:%M"), else: "")
|
||||
}
|
||||
|
||||
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_edit", %{"edit" => _params}, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("submit_edit", %{"edit" => params}, socket) do
|
||||
user = current_user(socket.assigns)
|
||||
|
||||
if is_nil(user) do
|
||||
{:noreply, put_flash(socket, :error, "You must be logged in to suggest edits.")}
|
||||
else
|
||||
contact = socket.assigns.contact
|
||||
|
||||
case Radio.create_contact_edit(contact, user, params) do
|
||||
{:ok, _edit} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(editing: false, edit_form: nil, pending_edit: Radio.pending_edit_for_user(contact.id, user.id))
|
||||
|> put_flash(:info, "Edit submitted for review.")}
|
||||
|
||||
{:error, changeset} ->
|
||||
messages =
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
|
||||
|> Enum.flat_map(fn {_field, msgs} -> msgs end)
|
||||
|> Enum.join(", ")
|
||||
|
||||
{:noreply, put_flash(socket, :error, "Could not submit edit: #{messages}")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:terrain_ready, _contact_id}, socket) do
|
||||
contact = %{socket.assigns.contact | terrain_status: :complete}
|
||||
|
|
@ -447,6 +520,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
<% end %>
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<%= if current_user(assigns) do %>
|
||||
<button
|
||||
phx-click="toggle_edit"
|
||||
class={["btn btn-sm", if(@editing, do: "btn-ghost", else: "btn-outline")]}
|
||||
>
|
||||
<.icon
|
||||
name={if @editing, do: "hero-x-mark", else: "hero-pencil-square"}
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{if @editing, do: "Cancel Edit", else: "Suggest Edit"}
|
||||
</button>
|
||||
<% end %>
|
||||
<button
|
||||
phx-click="toggle_flag"
|
||||
class={[
|
||||
|
|
@ -463,6 +548,53 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%= if @pending_edit && !@editing do %>
|
||||
<div class="bg-info/10 border border-info/30 rounded-box px-4 py-2 text-sm mb-4">
|
||||
<.icon name="hero-clock" class="w-4 h-4 inline" />
|
||||
You have a pending edit for this contact awaiting admin review.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @editing do %>
|
||||
<div class="bg-base-200 rounded-box p-4 mb-4">
|
||||
<h3 class="font-semibold mb-3">Suggest Edit</h3>
|
||||
<p class="text-sm opacity-70 mb-4">
|
||||
Change any fields below. Only fields you modify will be submitted for review.
|
||||
</p>
|
||||
<.form
|
||||
for={@edit_form}
|
||||
id="edit-form"
|
||||
phx-change="validate_edit"
|
||||
phx-submit="submit_edit"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<.input field={@edit_form[:station1]} type="text" label="Station 1" />
|
||||
<.input field={@edit_form[:grid1]} type="text" label="Grid 1" />
|
||||
<.input field={@edit_form[:station2]} type="text" label="Station 2" />
|
||||
<.input field={@edit_form[:grid2]} type="text" label="Grid 2" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} />
|
||||
<.input field={@edit_form[:mode]} type="select" label="Mode" options={@mode_options} />
|
||||
<.input
|
||||
field={@edit_form[:qso_timestamp]}
|
||||
type="datetime-local"
|
||||
label="Timestamp (UTC)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<.button type="submit" class="btn btn-primary btn-sm">
|
||||
<.icon name="hero-paper-airplane" class="w-4 h-4" /> Submit for Review
|
||||
</.button>
|
||||
<button type="button" phx-click="toggle_edit" class="btn btn-ghost btn-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @contact.pos1 && @contact.pos2 do %>
|
||||
<p class="text-xs text-base-content/50 italic mb-1">
|
||||
Locations approximate, in the center of the grid squares
|
||||
|
|
|
|||
|
|
@ -74,7 +74,9 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
</div>
|
||||
<div class="fixed top-3 left-14 z-[1000] bg-base-100 shadow rounded-box border border-base-300 px-3 py-2 flex flex-col gap-1">
|
||||
<div class="font-bold text-sm">Contact Map</div>
|
||||
<div id="contact-count" class="text-xs opacity-70">{Integer.to_string(@contact_count)} contacts</div>
|
||||
<div id="contact-count" class="text-xs opacity-70">
|
||||
{Integer.to_string(@contact_count)} contacts
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
id="map-controls"
|
||||
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
||||
>
|
||||
<div data-theme="dark" class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2">
|
||||
<div
|
||||
data-theme="dark"
|
||||
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
||||
>
|
||||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<span>NTMS</span>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/beacons/:id/edit", BeaconLive.Form, :edit
|
||||
|
||||
live "/admin/backfill", BackfillLive
|
||||
live "/admin/contact-edits", Admin.ContactEditLive
|
||||
|
||||
live "/users", UserManagementLive.Index, :index
|
||||
live "/users/:id/edit", UserManagementLive.Edit, :edit
|
||||
|
|
|
|||
25
priv/repo/migrations/20260411210308_create_contact_edits.exs
Normal file
25
priv/repo/migrations/20260411210308_create_contact_edits.exs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.CreateContactEdits do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:contact_edits, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :contact_id, references(:contacts, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :proposed_changes, :map, null: false
|
||||
add :status, :string, null: false, default: "pending"
|
||||
add :admin_note, :string
|
||||
add :reviewed_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
add :reviewed_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:contact_edits, [:contact_id])
|
||||
create index(:contact_edits, [:user_id])
|
||||
create index(:contact_edits, [:status])
|
||||
end
|
||||
end
|
||||
248
test/microwaveprop/radio/contact_edit_test.exs
Normal file
248
test/microwaveprop/radio/contact_edit_test.exs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
defmodule Microwaveprop.Radio.ContactEditTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Accounts
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.ContactEdit
|
||||
|
||||
@contact_attrs %{
|
||||
station1: "W5ISP",
|
||||
station2: "K5TR",
|
||||
qso_timestamp: ~U[2026-04-01 14:00:00Z],
|
||||
grid1: "EM13",
|
||||
grid2: "EL29",
|
||||
pos1: %{"lat" => 33.5, "lon" => -97.0},
|
||||
pos2: %{"lat" => 29.5, "lon" => -98.5},
|
||||
mode: "CW",
|
||||
band: Decimal.new("10000"),
|
||||
distance_km: Decimal.new("450")
|
||||
}
|
||||
|
||||
@user_attrs %{
|
||||
callsign: "W5TEST",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "testpassword123"
|
||||
}
|
||||
|
||||
@admin_attrs %{
|
||||
callsign: "N5ADM",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
password: "adminpassword123"
|
||||
}
|
||||
|
||||
defp create_contact(_) do
|
||||
{:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Repo.insert()
|
||||
%{contact: contact}
|
||||
end
|
||||
|
||||
defp create_user(_) do
|
||||
{:ok, user} = Accounts.register_user(@user_attrs)
|
||||
%{user: user}
|
||||
end
|
||||
|
||||
defp create_admin(_) do
|
||||
{:ok, admin} = Accounts.register_user(@admin_attrs)
|
||||
admin = admin |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||
%{admin: admin}
|
||||
end
|
||||
|
||||
describe "ContactEdit.changeset/2" do
|
||||
setup [:create_contact, :create_user]
|
||||
|
||||
test "valid with proper proposed changes", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{"grid1" => "EM13kk"}
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "rejects empty proposed_changes", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{}
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "must contain at least one change" in errors_on(changeset).proposed_changes
|
||||
end
|
||||
|
||||
test "rejects invalid fields in proposed_changes", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{"distance_km" => 100}
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert Enum.any?(errors_on(changeset).proposed_changes, &String.contains?(&1, "invalid fields"))
|
||||
end
|
||||
|
||||
test "validates callsign format", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{"station1" => "bad call!"}
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
end
|
||||
|
||||
test "validates grid format", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{"grid1" => "ZZZZZZ"}
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
end
|
||||
|
||||
test "validates band value", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{"band" => 99_999}
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
end
|
||||
|
||||
test "validates mode value", %{contact: contact, user: user} do
|
||||
changeset =
|
||||
ContactEdit.changeset(%ContactEdit{}, %{
|
||||
contact_id: contact.id,
|
||||
user_id: user.id,
|
||||
proposed_changes: %{"mode" => "INVALID"}
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
end
|
||||
|
||||
test "requires contact_id, user_id, proposed_changes" do
|
||||
changeset = ContactEdit.changeset(%ContactEdit{}, %{})
|
||||
|
||||
assert %{
|
||||
contact_id: ["can't be blank"],
|
||||
user_id: ["can't be blank"],
|
||||
proposed_changes: ["can't be blank"]
|
||||
} = errors_on(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Radio.create_contact_edit/3" do
|
||||
setup [:create_contact, :create_user]
|
||||
|
||||
test "creates a pending edit with only changed fields", %{contact: contact, user: user} do
|
||||
proposed = %{"grid1" => "EM13kk", "station1" => "W5ISP"}
|
||||
|
||||
assert {:ok, edit} = Radio.create_contact_edit(contact, user, proposed)
|
||||
assert edit.status == :pending
|
||||
assert edit.proposed_changes == %{"grid1" => "EM13KK"}
|
||||
assert edit.contact_id == contact.id
|
||||
assert edit.user_id == user.id
|
||||
end
|
||||
|
||||
test "rejects when nothing actually changed", %{contact: contact, user: user} do
|
||||
proposed = %{"station1" => "W5ISP", "mode" => "CW"}
|
||||
|
||||
assert {:error, changeset} = Radio.create_contact_edit(contact, user, proposed)
|
||||
assert "must contain at least one change" in errors_on(changeset).proposed_changes
|
||||
end
|
||||
end
|
||||
|
||||
describe "Radio.list_pending_edits/0" do
|
||||
setup [:create_contact, :create_user]
|
||||
|
||||
test "returns pending edits", %{contact: contact, user: user} do
|
||||
{:ok, _e1} =
|
||||
Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
|
||||
|
||||
{:ok, _e2} =
|
||||
Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"})
|
||||
|
||||
edits = Radio.list_pending_edits()
|
||||
assert length(edits) == 2
|
||||
|
||||
fields = Enum.flat_map(edits, &Map.keys(&1.proposed_changes))
|
||||
assert "grid1" in fields
|
||||
assert "grid2" in fields
|
||||
end
|
||||
end
|
||||
|
||||
describe "Radio.approve_edit/3" do
|
||||
setup [:create_contact, :create_user, :create_admin]
|
||||
|
||||
test "applies changes to the contact and marks approved", %{
|
||||
contact: contact,
|
||||
user: user,
|
||||
admin: admin
|
||||
} do
|
||||
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
|
||||
|
||||
assert {:ok, approved_edit} = Radio.approve_edit(edit, admin, "Confirmed via LOTW")
|
||||
assert approved_edit.status == :approved
|
||||
assert approved_edit.admin_note == "Confirmed via LOTW"
|
||||
assert approved_edit.reviewed_by_id == admin.id
|
||||
assert approved_edit.reviewed_at
|
||||
|
||||
updated_contact = Radio.get_contact!(contact.id)
|
||||
assert updated_contact.grid1 == "EM13KK"
|
||||
end
|
||||
|
||||
test "re-enqueues enrichment when grid changes", %{
|
||||
contact: contact,
|
||||
user: user,
|
||||
admin: admin
|
||||
} do
|
||||
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM15ab"})
|
||||
{:ok, _approved} = Radio.approve_edit(edit, admin, nil)
|
||||
|
||||
updated = Radio.get_contact!(contact.id)
|
||||
assert updated.grid1 == "EM15AB"
|
||||
assert updated.pos1 != contact.pos1
|
||||
end
|
||||
end
|
||||
|
||||
describe "Radio.reject_edit/3" do
|
||||
setup [:create_contact, :create_user, :create_admin]
|
||||
|
||||
test "marks edit as rejected without changing contact", %{
|
||||
contact: contact,
|
||||
user: user,
|
||||
admin: admin
|
||||
} do
|
||||
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
|
||||
|
||||
assert {:ok, rejected_edit} = Radio.reject_edit(edit, admin, "Original data correct")
|
||||
assert rejected_edit.status == :rejected
|
||||
assert rejected_edit.admin_note == "Original data correct"
|
||||
|
||||
unchanged_contact = Radio.get_contact!(contact.id)
|
||||
assert unchanged_contact.grid1 == contact.grid1
|
||||
end
|
||||
end
|
||||
|
||||
describe "Radio.pending_edit_count/0" do
|
||||
setup [:create_contact, :create_user]
|
||||
|
||||
test "counts only pending edits", %{contact: contact, user: user} do
|
||||
{:ok, _} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
|
||||
{:ok, _} = Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"})
|
||||
|
||||
assert Radio.pending_edit_count() == 2
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue