Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@
!/test/**/*.test.exs

# versioned migrations
!/priv/
/priv/*
!/priv/repo/
/priv/repo/*
!/priv/repo/migrations
/priv/repo/migrations/*
!/priv/repo/migrations/*.exs
# !/priv/
# /priv/*
# !/priv/repo/
# /priv/repo/*
# !/priv/repo/migrations
# /priv/repo/migrations/*
# !/priv/repo/migrations/*.exs

# deps
!/.deps/
Expand Down
60 changes: 58 additions & 2 deletions src/zenflows/vf/economic_resource/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@ import Ecto.Query

alias Ecto.{Changeset, Queryable}
alias Zenflows.DB.{ID, Page, Schema, Validate}
alias Zenflows.VF.{EconomicEvent, EconomicResource}
alias Zenflows.VF.{EconomicEvent, EconomicResource, SpatialThing}

@spec all(Page.t()) :: {:ok, Queryable.t()} | {:error, Changeset.t()}
def all(%{filter: nil}), do: {:ok, EconomicResource}
def all(%{filter: params}) do
with {:ok, filters} <- all_validate(params) do
{:ok, Enum.reduce(filters, EconomicResource, &all_f(&2, &1))}
{geo_filters, other_filters} = Enum.split_with(filters, fn
{k, _} -> k in [:near_lat, :near_long, :near_distance_km]
_ -> false
end)
q = Enum.reduce(other_filters, EconomicResource, &all_f(&2, &1))
q = apply_geo_filter(q, Map.new(geo_filters))
{:ok, q}
end
end

Expand Down Expand Up @@ -75,6 +81,19 @@ defp all_f(q, {:repo, v}),
defp all_f(q, {:or_repo, v}),
do: or_where(q, [x], x.repo == ^v)

@spec apply_geo_filter(Queryable.t(), map()) :: Queryable.t()
defp apply_geo_filter(q, %{near_lat: lat, near_long: long, near_distance_km: dist}) do
from x in q,
join: loc in SpatialThing,
on: loc.id == x.current_location_id,
where: not is_nil(loc.lat) and not is_nil(loc.long),
where: fragment(
"(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(?)) * cos(radians(?) - radians(?)) + sin(radians(?)) * sin(radians(?))))) <= ?",
^lat, loc.lat, loc.long, ^long, ^lat, loc.lat, ^dist
)
end
defp apply_geo_filter(q, _), do: q

@spec all_validate(Schema.params())
:: {:ok, Changeset.data()} | {:error, Changeset.t()}
defp all_validate(params) do
Expand All @@ -99,6 +118,9 @@ defp all_validate(params) do
or_note: :string,
repo: :string,
or_repo: :string,
near_lat: :decimal,
near_long: :decimal,
near_distance_km: :decimal,
}}
|> Changeset.cast(params, ~w[
id or_id
Expand All @@ -109,6 +131,7 @@ defp all_validate(params) do
gt_onhand_quantity_has_numerical_value
or_gt_onhand_quantity_has_numerical_value
name or_name note or_note repo or_repo
near_lat near_long near_distance_km
]a)
|> Validate.class(:id)
|> Validate.class(:or_id)
Expand Down Expand Up @@ -146,9 +169,42 @@ defp all_validate(params) do
|> Validate.escape_like(:or_name)
|> Validate.escape_like(:note)
|> Validate.escape_like(:or_note)
|> validate_geo_params()
|> Changeset.apply_action(nil)
end

@spec validate_geo_params(Changeset.t()) :: Changeset.t()
defp validate_geo_params(cset) do
near_lat = Changeset.get_change(cset, :near_lat)
near_long = Changeset.get_change(cset, :near_long)
near_dist = Changeset.get_change(cset, :near_distance_km)

geo_fields = [near_lat, near_long, near_dist]
provided = Enum.count(geo_fields, & &1 != nil)

cond do
provided == 0 ->
cset
provided != 3 ->
cset
|> maybe_geo_error(:near_lat, near_lat, "near_lat, near_long, and near_distance_km must all be provided together")
|> maybe_geo_error(:near_long, near_long, "near_lat, near_long, and near_distance_km must all be provided together")
|> maybe_geo_error(:near_distance_km, near_dist, "near_lat, near_long, and near_distance_km must all be provided together")
true ->
cset
|> Changeset.validate_number(:near_lat,
greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|> Changeset.validate_number(:near_long,
greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|> Changeset.validate_number(:near_distance_km,
greater_than: 0)
end
end

defp maybe_geo_error(cset, field, nil, msg),
do: Changeset.add_error(cset, field, msg)
defp maybe_geo_error(cset, _field, _val, _msg), do: cset

@spec previous(Schema.id()) :: Queryable.t()
def previous(id) do
from e in EconomicEvent,
Expand Down
7 changes: 7 additions & 0 deletions src/zenflows/vf/economic_resource/type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@ input_object :economic_resource_filter_params do
field :or_note, :string
field :repo, :string
field :or_repo, :string

@desc "Latitude of the center point for geo search."
field :near_lat, :decimal
@desc "Longitude of the center point for geo search."
field :near_long, :decimal
@desc "Search radius in kilometers for geo search."
field :near_distance_km, :decimal
end

input_object :economic_resource_classifications_filter_params do
Expand Down
163 changes: 163 additions & 0 deletions test/vf/economic_resource/geo_search.test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Zenflows is software that implements the Valueflows vocabulary.
# Zenflows is designed, written, and maintained by srfsh <srfsh@dyne.org>
# Copyright (C) 2021-2023 Dyne.org foundation <foundation@dyne.org>.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

defmodule ZenflowsTest.VF.EconomicResource.GeoSearch do
use ZenflowsTest.Help.EctoCase, async: true

alias Zenflows.DB.Page
alias Zenflows.VF.EconomicResource.Domain

# Helper to create a SpatialThing at given coordinates
defp insert_spatial_thing!(lat, long) do
Factory.insert!(:spatial_thing, %{
lat: Decimal.from_float(lat),
long: Decimal.from_float(long),
})
end

# Helper to create an EconomicResource at a given location
defp insert_resource_at!(location) do
agent = Factory.insert!(:agent)
%{resource_inventoried_as_id: res_id} =
Zenflows.VF.EconomicEvent.Domain.create!(%{
action_id: "raise",
provider_id: agent.id,
receiver_id: agent.id,
resource_classified_as: Factory.str_list("some uri"),
resource_conforms_to_id: Factory.insert!(:resource_specification).id,
resource_quantity: %{
has_numerical_value: Factory.decimald(),
has_unit_id: Factory.insert!(:unit).id,
},
has_point_in_time: Factory.now(),
to_location_id: location.id,
}, %{name: Factory.str("some name")})
Domain.one!(res_id)
end

describe "geo search filter" do
test "returns resources within the given radius" do
# Rome, Italy (41.9028, 12.4964)
rome = insert_spatial_thing!(41.9028, 12.4964)
# Milan, Italy (45.4642, 9.1900) - ~480km from Rome
milan = insert_spatial_thing!(45.4642, 9.1900)
# Paris, France (48.8566, 2.3522) - ~1100km from Rome
paris = insert_spatial_thing!(48.8566, 2.3522)

res_rome = insert_resource_at!(rome)
res_milan = insert_resource_at!(milan)
_res_paris = insert_resource_at!(paris)

# Search 500km around Rome: should find Rome and Milan but not Paris
page = Page.new(%{filter: %{
near_lat: "41.9028",
near_long: "12.4964",
near_distance_km: "500",
}})
{:ok, results} = Domain.all(page)
result_ids = MapSet.new(results, & &1.id)

assert MapSet.member?(result_ids, res_rome.id)
assert MapSet.member?(result_ids, res_milan.id)
refute MapSet.member?(result_ids, _res_paris.id)
end

test "returns only nearby resources with small radius" do
# Rome
rome = insert_spatial_thing!(41.9028, 12.4964)
# Milan (~480km away)
milan = insert_spatial_thing!(45.4642, 9.1900)

res_rome = insert_resource_at!(rome)
_res_milan = insert_resource_at!(milan)

# Search 100km around Rome: should find only Rome
page = Page.new(%{filter: %{
near_lat: "41.9028",
near_long: "12.4964",
near_distance_km: "100",
}})
{:ok, results} = Domain.all(page)
result_ids = MapSet.new(results, & &1.id)

assert MapSet.member?(result_ids, res_rome.id)
refute MapSet.member?(result_ids, _res_milan.id)
end

test "excludes resources without a location" do
rome = insert_spatial_thing!(41.9028, 12.4964)
res_rome = insert_resource_at!(rome)

# Create a resource without location (standard factory)
res_no_loc = Factory.insert!(:economic_resource)

page = Page.new(%{filter: %{
near_lat: "41.9028",
near_long: "12.4964",
near_distance_km: "50000",
}})
{:ok, results} = Domain.all(page)
result_ids = MapSet.new(results, & &1.id)

assert MapSet.member?(result_ids, res_rome.id)
refute MapSet.member?(result_ids, res_no_loc.id)
end

test "validates all three geo params must be provided together" do
page = Page.new(%{filter: %{
near_lat: "41.9028",
near_long: "12.4964",
}})
assert {:error, %Ecto.Changeset{}} = Domain.all(page)
end

test "validates lat range" do
page = Page.new(%{filter: %{
near_lat: "91.0",
near_long: "12.0",
near_distance_km: "100",
}})
assert {:error, %Ecto.Changeset{}} = Domain.all(page)
end

test "validates distance must be positive" do
page = Page.new(%{filter: %{
near_lat: "41.0",
near_long: "12.0",
near_distance_km: "0",
}})
assert {:error, %Ecto.Changeset{}} = Domain.all(page)
end

test "combines geo filter with other filters" do
rome = insert_spatial_thing!(41.9028, 12.4964)
res_rome = insert_resource_at!(rome)

page = Page.new(%{filter: %{
near_lat: "41.9028",
near_long: "12.4964",
near_distance_km: "100",
name: res_rome.name,
}})
{:ok, results} = Domain.all(page)
result_ids = MapSet.new(results, & &1.id)

assert MapSet.member?(result_ids, res_rome.id)
end
end
end
Loading