Profile page:
* New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
page showing a user's contacts and beacons. Resolves case-
insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
callsigns redirect to /. Uses daisyUI card / stats / table
components with an avatar-placeholder initial and hero icons.
* Accounts.get_user_by_callsign/1 (case-insensitive) plus
Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
back the page. Beacons list includes both approved and pending so
owners see their drafts.
* The top nav bar and the three LiveView sidebars (MapLive,
WeatherMapLive, ContactMapLive) now render the logged-in callsign
as a navigate link to /u/:callsign instead of a static label.
* Nine new tests cover the lookup, the LiveView render, and the
ownership-scoped queries.
Flexible band input:
* New Microwaveprop.Radio.BandResolver module converts any of:
ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
insensitive), numeric frequency strings ("903.100", "10368.000"),
and canonical MHz integers into the one of the site's known bands.
Returns the nearest allowed band for numeric inputs >= 900 MHz,
nil otherwise.
* 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
AdifImport.@allowed_bands, and the BandResolver list so "33cm"
round-trips end-to-end.
* AdifImport and CsvImport now delegate band resolution to
BandResolver, and Radio.create_contact/2 normalizes the :band attr
on the way in so the manual form and any API callers benefit too.
CsvImport's "invalid band" tests previously used 99999 MHz which
the new resolver snaps to the nearest allowed band; swapped to
"notaband" which is truly unresolvable.
Contacts and beacons list UX:
* Remove the "Submitted" column from /contacts — it duplicated info
already visible on the detail page and was pushing the real
columns off narrow viewports. submitted_cell/1 and its three
column-specific tests go with it.
* Hide the Lat / Lon columns from /beacons — six decimal places of
coordinates weren't useful next to the grid square and took a
disproportionate amount of row width.
144 lines
4.4 KiB
Elixir
144 lines
4.4 KiB
Elixir
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 Q65)
|
|
@allowed_bands ~w(902 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
|
|
|
|
@type t :: %__MODULE__{}
|
|
|
|
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
|
|
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
|
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
|
|
|
|
@spec review_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
|
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
|