diff --git a/config/config.exs b/config/config.exs index f71ae1f..2c18cc1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -63,6 +63,12 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# Use Oban for background jobs +config :munch, Oban, + engine: Oban.Engines.Basic, + queues: [default: 10], + repo: Munch.Repo + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/test.exs b/config/test.exs index f2ae508..763ed92 100644 --- a/config/test.exs +++ b/config/test.exs @@ -39,3 +39,5 @@ config :phoenix, :plug_init_mode, :runtime # Enable helpful, but potentially expensive runtime checks config :phoenix_live_view, enable_expensive_runtime_checks: true + +config :munch, Oban, testing: :inline diff --git a/lib/munch/application.ex b/lib/munch/application.ex index 12c27b4..6890eb8 100644 --- a/lib/munch/application.ex +++ b/lib/munch/application.ex @@ -10,6 +10,7 @@ defmodule Munch.Application do children = [ MunchWeb.Telemetry, Munch.Repo, + {Oban, Application.get_env(:munch, Oban)}, {DNSCluster, query: Application.get_env(:munch, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Munch.PubSub}, # Start a worker by calling: Munch.Worker.start_link(arg) diff --git a/lib/munch/importer.ex b/lib/munch/importer.ex new file mode 100644 index 0000000..3cbb63b --- /dev/null +++ b/lib/munch/importer.ex @@ -0,0 +1,11 @@ +defmodule Munch.Importer do + use Oban.Worker, queue: :fresh_restaurants + + alias Munch.Restaurants + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"osm_type" => osm_type, "osm_id" => osm_id}}) do + {:ok, restaurant} = Munch.Osm.nominatim_get_details(osm_type, osm_id) + Restaurants.create_restaurant(restaurant) + end +end diff --git a/lib/munch/osm.ex b/lib/munch/osm.ex index eb416a2..8f3267e 100644 --- a/lib/munch/osm.ex +++ b/lib/munch/osm.ex @@ -7,60 +7,149 @@ defmodule Munch.Osm do alias Munch.Repo alias Munch.Restaurants - def api_get_object(osm_type, osm_id) do - resp = Req.get!("https://api.openstreetmap.org/api/0.6/#{osm_type}/#{osm_id}.json") + def osm_get!(path) do + Req.get!( + url: path, + base_url: "https://api.openstreetmap.org/api/0.6", + headers: [user_agent: "Munch/0.1"] + ) + end + + def nominatim_get!(path) do + Req.get!( + url: path, + base_url: "https://nominatim.openstreetmap.org", + headers: [user_agent: "Munch/0.1"] + ) + end - decoded = Jason.decode!(resp.body) + def osm_get_object(:node, osm_id) do + resp = osm_get!("node/#{osm_id}.json") - case decoded do + case resp.body do %{"elements" => [%{"lat" => lattitude, "lon" => longitude, "tags" => tags}]} -> - %{lattitude: lattitude, longitude: longitude, tags: tags} + {:ok, %{lattitude: lattitude, longitude: longitude, tags: tags}} _ -> - raise "Invalid response from OpenStreetMaps API: #{decoded}" + {:error, "Invalid response from OpenStreetMaps API: #{resp}"} end end - def pull_restaurant(osm_type, osm_id) do - object = api_get_object(osm_type, osm_id) - %{"name" => name} = object.tags - - Repo.insert!(%Restaurants.Restaurant{ - osm_type: osm_type, - osm_id: osm_id, - name: name, - location: %Geo.Point{coordinates: {object.longitude, object.lattitude}, srid: 4326}, - note: "", - iso_country_subdivision: "", - secondary_subdivision: "" - }) + def osm_get_object(:way, _osm_id) do + {:error, "Importing ways is not implemented yet"} + end + + def osm_get_object(:relation, _osm_id) do + {:error, "Importing relations is not implemented yet"} + end + + @doc """ + Import a restaurant and queue a task to pull its details from Nominatim. + """ + def import_fresh_restaurant(osm_type, osm_id) do + with {:ok, object} <- osm_get_object(osm_type, osm_id), + {:ok, name} <- Map.fetch(object.tags, "name") do + point = %Geo.Point{coordinates: {object.longitude, object.lattitude}, srid: 4326} + + Oban.insert( + Munch.Importer.new(%{ + "osm_type" => osm_type, + "osm_id" => osm_id + }) + ) + + Repo.insert!(%Restaurants.Restaurant{ + osm_type: osm_type, + osm_id: osm_id, + name: name, + location: point, + note: "", + country: "", + iso_country_subdivision: "" + }) + end end - def copy_osm_restaurants() do - osm_restaurants = Repo.all(Munch.Osm.Restaurant) - - restaurants = - osm_restaurants - |> Enum.map(fn osm_restaurant -> - %Restaurants.Restaurant{ - osm_type: osm_restaurant.osm_type, - osm_id: osm_restaurant.osm_id, - name: osm_restaurant.tags["name"], - location: osm_restaurant.location, - note: "", - iso_country_subdivision: nil, - secondary_subdivision: nil + def nominatim_get_details(osm_type, osm_id) do + osm_type_char = + case osm_type do + :node -> "N" + :way -> "W" + :relation -> "R" + end + + resp = nominatim_get!("lookup?osm_ids=#{osm_type_char}#{osm_id}&format=jsonv2") + + case resp.body do + [ + %{ + "lat" => latitude, + "lon" => longitude, + "name" => name, + "display_name" => display_name, + "address" => + %{ + "ISO3166-2-lvl4" => iso_country_subdivision, + "country" => country + } = address } - end) - |> IO.inspect() + ] -> + {:ok, + %{ + osm_type: osm_type, + osm_id: osm_id, + name: name, + display_name: display_name, + location: %Geo.Point{ + coordinates: {String.to_float(longitude), String.to_float(latitude)}, + srid: 4326 + }, + note: "", + country: country, + iso_country_subdivision: iso_country_subdivision, + address: address + }} - restaurants - |> Enum.map(fn restaurant -> - Repo.insert!(restaurant) - end) + _ -> + {:error, "Invalid response from Nominatim API: #{resp}"} + end end - def sync_restaurant(_restaurant) do - IO.inspect("TODO: Implement sync with OSM") + @doc """ + Search for restaurants with Nominatim. + + options: + + * `:exclude` - A list of place IDs to exclude from the results. + """ + def search_restaurants(search, opts \\ []) do + exclude = Keyword.get(opts, :exclude, []) + # TODO: use AI to assemble a structured query from the search string + resp = + nominatim_get!( + "search?q=#{search}&layer=poi&exclude=#{exclude |> Enum.join(",")}&format=jsonv2" + ) + + resp.body + |> Map.new(fn x -> + case x do + %{ + "place_id" => place_id, + "lat" => lattitude, + "lon" => longitude, + "display_name" => display_name, + "osm_type" => osm_type, + "osm_id" => osm_id + } -> + {place_id, + %{ + lattitude: lattitude, + longitude: longitude, + display_name: display_name, + osm_type: String.to_atom(osm_type), + osm_id: osm_id + }} + end + end) end end diff --git a/lib/munch/osm/restaurant.ex b/lib/munch/osm/restaurant.ex deleted file mode 100644 index 7c3a33a..0000000 --- a/lib/munch/osm/restaurant.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Munch.Osm.Restaurant do - use Ecto.Schema - - @schema_prefix "osm" - @primary_key false - schema "restaurants" do - field :osm_type, Ecto.Enum, values: [node: "N", way: "W", relation: "R"], primary_key: true - field :osm_id, :integer, primary_key: true - field :location, Geo.PostGIS.Geometry - field :tags, :map - end -end diff --git a/lib/munch/restaurants.ex b/lib/munch/restaurants.ex index 7e5d426..5522741 100644 --- a/lib/munch/restaurants.ex +++ b/lib/munch/restaurants.ex @@ -22,13 +22,7 @@ defmodule Munch.Restaurants do end @doc """ - Search restaurants by name or address. Words are aggregated with AND. - - ## Examples - - iex> search_restaurants("Ma") - [%Restaurant{}, ...] - + Search local restaurants by name or address. Words are aggregated with AND. """ def search_restaurants(search) do words = String.split(search) diff --git a/lib/munch/restaurants/restaurant.ex b/lib/munch/restaurants/restaurant.ex index 6cfc4cf..04eedf1 100644 --- a/lib/munch/restaurants/restaurant.ex +++ b/lib/munch/restaurants/restaurant.ex @@ -8,10 +8,12 @@ defmodule Munch.Restaurants.Restaurant do field :osm_type, Ecto.Enum, values: [node: "N", way: "W", relation: "R"] field :osm_id, :integer field :name, :string + field :display_name, :string field :location, Geo.PostGIS.Geometry field :note, :string + field :country, :string field :iso_country_subdivision, :string - field :secondary_subdivision, :string + field :address, :map timestamps(type: :utc_datetime) end @@ -19,6 +21,16 @@ defmodule Munch.Restaurants.Restaurant do @doc false def changeset(restaurant, attrs) do restaurant - |> cast(attrs, [:note]) + |> cast(attrs, [ + :osm_type, + :osm_id, + :name, + :display_name, + :location, + :note, + :country, + :iso_country_subdivision, + :address + ]) end end diff --git a/lib/munch_web/live/restaurant_live/edit.ex b/lib/munch_web/live/restaurant_live/edit.ex index 7862b57..5a0ed14 100644 --- a/lib/munch_web/live/restaurant_live/edit.ex +++ b/lib/munch_web/live/restaurant_live/edit.ex @@ -59,7 +59,11 @@ defmodule MunchWeb.RestaurantLive.Edit do end def handle_event("trigger_sync", _params, socket) do - Osm.sync_restaurant(socket.assigns.restaurant) + Osm.import_fresh_restaurant( + socket.assigns.restaurant.osm_type, + socket.assigns.restaurant.osm_id + ) + {:noreply, socket} end diff --git a/lib/munch_web/live/restaurant_live/import.ex b/lib/munch_web/live/restaurant_live/import.ex new file mode 100644 index 0000000..8ce30ce --- /dev/null +++ b/lib/munch_web/live/restaurant_live/import.ex @@ -0,0 +1,54 @@ +defmodule MunchWeb.RestaurantLive.Import do + use MunchWeb, :live_view + + alias Munch.Osm + alias Munch.Restaurants + + @impl true + def render(assigns) do + ~H""" + <.header> + Add a new restaurant by search + + + <.simple_form for={@form} id="search-new-form" phx-submit="search"> + <.input field={@form[:search]} type="text" label="Search" /> + <:actions> + <.button phx-disable-with="Searching...">Search + + +
- New restaurants and other changes to the OpenStreetMap map should be - imported automatically every 24 hours. You can import a restaurant - ahead of schedule by entering its osm_type and osm_id below. + If you can't find the restaurant by search, you can import a restaurant + by entering its osm_type and osm_id below.
<.simple_form for={@form} id="osm-form" phx-submit="save"> <.input @@ -52,7 +51,7 @@ defmodule MunchWeb.RestaurantLive.New do @impl true def handle_event("save", %{"osm_type" => osm_type, "osm_id" => osm_id}, socket) do - case Osm.pull_restaurant(osm_type, osm_id) do + case Osm.import_fresh_restaurant(osm_type, osm_id) do {:ok, restaurant} -> {:noreply, socket diff --git a/lib/munch_web/live/restaurant_live/index.ex b/lib/munch_web/live/restaurant_live/index.ex index 9e54103..0cc8192 100644 --- a/lib/munch_web/live/restaurant_live/index.ex +++ b/lib/munch_web/live/restaurant_live/index.ex @@ -21,7 +21,9 @@ defmodule MunchWeb.RestaurantLive.Index do row_click={fn {_id, restaurant} -> JS.navigate(~p"/restaurant/#{restaurant}") end} > <:col :let={{_id, restaurant}} label="Name"><%= restaurant.name %> - <:col :let={{_id, restaurant}} label="Address"><%= restaurant.address %> + <:col :let={{_id, restaurant}} label="Address"> + <%= restaurant.osm_type %> <%= restaurant.osm_id %> + <:action :let={{_id, restaurant}}>