feat(geo): Photon proxy, address-only location, nearby search. Refs EventHub/EventHubBack#77
This commit is contained in:
@@ -43,4 +43,8 @@ REMINDER_LEAD_HOURS=24
|
|||||||
# Uploads (avatar/cover) — каталог на volume /app/data (Back#71)
|
# Uploads (avatar/cover) — каталог на volume /app/data (Back#71)
|
||||||
UPLOAD_DIR=/app/data/uploads
|
UPLOAD_DIR=/app/data/uploads
|
||||||
UPLOAD_MAX_BYTES=2097152
|
UPLOAD_MAX_BYTES=2097152
|
||||||
|
# Photon (DevOps#16). Пусто = /v1/geo → 503. Включить: PHOTON_REPLICAS=1 и PHOTON_URL=http://photon:2322
|
||||||
|
PHOTON_URL=
|
||||||
|
PHOTON_TIMEOUT_MS=3000
|
||||||
|
PHOTON_REPLICAS=0
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ services:
|
|||||||
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
||||||
- CLUSTER_MODE=true
|
- CLUSTER_MODE=true
|
||||||
- DNS_NAME=eventhub-node
|
- DNS_NAME=eventhub-node
|
||||||
|
- PHOTON_URL=${PHOTON_URL:-}
|
||||||
|
- PHOTON_TIMEOUT_MS=${PHOTON_TIMEOUT_MS:-3000}
|
||||||
networks:
|
networks:
|
||||||
eventhub-net:
|
eventhub-net:
|
||||||
aliases:
|
aliases:
|
||||||
@@ -233,6 +235,29 @@ services:
|
|||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
window: 120s
|
window: 120s
|
||||||
|
|
||||||
|
# OSM geocoder (komoot Photon). Internal only — do not publish :2322.
|
||||||
|
# Default replicas 0. Enable: PHOTON_REPLICAS=1 PHOTON_URL=http://photon:2322
|
||||||
|
photon:
|
||||||
|
image: ${PHOTON_IMAGE:-rtuszik/photon-docker:latest}
|
||||||
|
environment:
|
||||||
|
- REGION=${PHOTON_REGION:-russia}
|
||||||
|
- UPDATE_STRATEGY=${PHOTON_UPDATE_STRATEGY:-PARALLEL}
|
||||||
|
volumes:
|
||||||
|
- photon-data:/photon/data
|
||||||
|
networks:
|
||||||
|
eventhub-net:
|
||||||
|
aliases:
|
||||||
|
- photon
|
||||||
|
deploy:
|
||||||
|
replicas: ${PHOTON_REPLICAS:-0}
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: ${PHOTON_MEMORY_LIMIT:-1536M}
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
eventhub-net:
|
eventhub-net:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
@@ -244,3 +269,4 @@ volumes:
|
|||||||
grafana-data:
|
grafana-data:
|
||||||
traefik-logs:
|
traefik-logs:
|
||||||
loglynx-data:
|
loglynx-data:
|
||||||
|
photon-data:
|
||||||
@@ -108,6 +108,9 @@ start_http() ->
|
|||||||
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
|
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
|
||||||
{"/v1/users/lookup", handler_users_lookup, []},
|
{"/v1/users/lookup", handler_users_lookup, []},
|
||||||
{"/v1/search", handler_search, []},
|
{"/v1/search", handler_search, []},
|
||||||
|
{"/v1/geo/suggest", handler_geo, []},
|
||||||
|
{"/v1/geo/geocode", handler_geo, []},
|
||||||
|
{"/v1/geo/reverse", handler_geo, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
|
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
|
||||||
|
|||||||
@@ -206,14 +206,7 @@ convert_field({<<"start_time">>, Val}) ->
|
|||||||
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
||||||
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
|
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
|
||||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
|
||||||
Loc = #location{
|
|
||||||
address = maps:get(<<"address">>, Val, undefined),
|
|
||||||
lat = maps:get(<<"lat">>, Val, undefined),
|
|
||||||
lon = maps:get(<<"lon">>, Val, undefined)
|
|
||||||
},
|
|
||||||
{location, Loc};
|
|
||||||
convert_field({<<"location">>, Val}) -> {location, Val};
|
|
||||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||||
|
|||||||
@@ -249,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
|
|||||||
RuleJson = jsx:encode(Val),
|
RuleJson = jsx:encode(Val),
|
||||||
{recurrence_rule, RuleJson};
|
{recurrence_rule, RuleJson};
|
||||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
|
||||||
Loc = #location{
|
|
||||||
address = maps:get(<<"address">>, Val, undefined),
|
|
||||||
lat = maps:get(<<"lat">>, Val, undefined),
|
|
||||||
lon = maps:get(<<"lon">>, Val, undefined)
|
|
||||||
},
|
|
||||||
{location, Loc};
|
|
||||||
convert_field({<<"location">>, Val}) -> {location, Val};
|
|
||||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||||
|
|||||||
@@ -302,14 +302,7 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
parse_location(undefined) -> undefined;
|
parse_location(undefined) -> undefined;
|
||||||
parse_location(LocationMap) when is_map(LocationMap) ->
|
parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
|
||||||
case LocationMap of
|
|
||||||
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
|
|
||||||
Address = maps:get(<<"address">>, LocationMap, <<"">>),
|
|
||||||
#location{address = Address, lat = Lat, lon = Lon};
|
|
||||||
_ -> undefined
|
|
||||||
end;
|
|
||||||
parse_location(_) -> undefined.
|
|
||||||
|
|
||||||
expand_recurring_events(UserId, Events, From, To) ->
|
expand_recurring_events(UserId, Events, From, To) ->
|
||||||
lists:flatmap(fun(Event) ->
|
lists:flatmap(fun(Event) ->
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc GET /v1/geo/suggest, POST /v1/geo/geocode, POST /v1/geo/reverse.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(handler_geo).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
|
||||||
|
-export([init/2, trails/0]).
|
||||||
|
|
||||||
|
init(Req, Opts) ->
|
||||||
|
handle(Req, Opts).
|
||||||
|
|
||||||
|
trails() ->
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
path => <<"/v1/geo/suggest">>,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"Address typeahead (Photon). Public + IP rate-limit.">>,
|
||||||
|
tags => [<<"Geo">>],
|
||||||
|
parameters => [
|
||||||
|
#{name => <<"q">>, in => <<"query">>, required => true, schema => #{type => string}},
|
||||||
|
#{name => <<"lang">>, in => <<"query">>, schema => #{type => string}}
|
||||||
|
],
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"OK">>},
|
||||||
|
400 => #{description => <<"invalid_query">>},
|
||||||
|
429 => #{description => <<"rate limited">>},
|
||||||
|
503 => #{description => <<"geo_unavailable">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{
|
||||||
|
path => <<"/v1/geo/geocode">>,
|
||||||
|
method => <<"POST">>,
|
||||||
|
description => <<"Forward geocode (Photon). Bearer required.">>,
|
||||||
|
tags => [<<"Geo">>],
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"OK">>},
|
||||||
|
401 => #{description => <<"Unauthorized">>},
|
||||||
|
404 => #{description => <<"not_found">>},
|
||||||
|
503 => #{description => <<"geo_unavailable">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{
|
||||||
|
path => <<"/v1/geo/reverse">>,
|
||||||
|
method => <<"POST">>,
|
||||||
|
description => <<"Reverse geocode (Photon). Bearer required.">>,
|
||||||
|
tags => [<<"Geo">>],
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"OK">>},
|
||||||
|
401 => #{description => <<"Unauthorized">>},
|
||||||
|
503 => #{description => <<"geo_unavailable">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
|
|
||||||
|
handle(Req, _Opts) ->
|
||||||
|
Path = cowboy_req:path(Req),
|
||||||
|
Method = cowboy_req:method(Req),
|
||||||
|
case {Path, Method} of
|
||||||
|
{<<"/v1/geo/suggest">>, <<"GET">>} -> suggest(Req);
|
||||||
|
{<<"/v1/geo/geocode">>, <<"POST">>} -> geocode(Req);
|
||||||
|
{<<"/v1/geo/reverse">>, <<"POST">>} -> reverse(Req);
|
||||||
|
{_, <<"GET">>} when Path =:= <<"/v1/geo/geocode">> orelse Path =:= <<"/v1/geo/reverse">> ->
|
||||||
|
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
|
||||||
|
{<<"/v1/geo/suggest">>, _} ->
|
||||||
|
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
|
||||||
|
_ ->
|
||||||
|
handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
suggest(Req) ->
|
||||||
|
Qs = cowboy_req:parse_qs(Req),
|
||||||
|
Q = proplists:get_value(<<"q">>, Qs, <<>>),
|
||||||
|
Lang = proplists:get_value(<<"lang">>, Qs, <<>>),
|
||||||
|
Key = <<"s:", (client_key(Req))/binary>>,
|
||||||
|
reply_suggest(Req, logic_geo:suggest(Q, Lang, Key)).
|
||||||
|
|
||||||
|
geocode(Req) ->
|
||||||
|
case handler_utils:auth_user(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||||
|
case decode_map(Body) of
|
||||||
|
{ok, Map} ->
|
||||||
|
Q = maps:get(<<"q">>, Map, <<>>),
|
||||||
|
Lang = maps:get(<<"lang">>, Map, <<>>),
|
||||||
|
Key = <<"g:", UserId/binary>>,
|
||||||
|
reply_hit(Req2, logic_geo:geocode(Q, Lang, Key), true);
|
||||||
|
error ->
|
||||||
|
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
|
|
||||||
|
reverse(Req) ->
|
||||||
|
case handler_utils:auth_user(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||||
|
case decode_map(Body) of
|
||||||
|
{ok, Map} ->
|
||||||
|
Lat = maps:get(<<"lat">>, Map, undefined),
|
||||||
|
Lon = maps:get(<<"lon">>, Map, undefined),
|
||||||
|
Lang = maps:get(<<"lang">>, Map, <<>>),
|
||||||
|
Key = <<"r:", UserId/binary>>,
|
||||||
|
reply_hit(Req2, logic_geo:reverse(Lat, Lon, Lang, Key), false);
|
||||||
|
error ->
|
||||||
|
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
|
|
||||||
|
reply_suggest(Req, {ok, Hits}) ->
|
||||||
|
handler_utils:send_json(Req, 200, #{<<"results">> => Hits});
|
||||||
|
reply_suggest(Req, {error, invalid_query}) ->
|
||||||
|
handler_utils:send_error(Req, 400, <<"invalid_query">>);
|
||||||
|
reply_suggest(Req, {error, rate_limited}) ->
|
||||||
|
handler_utils:send_error(Req, 429, <<"rate_limited">>);
|
||||||
|
reply_suggest(Req, {error, unavailable}) ->
|
||||||
|
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
|
||||||
|
|
||||||
|
reply_hit(Req, {ok, Hit}, _NotFound404) ->
|
||||||
|
handler_utils:send_json(Req, 200, Hit);
|
||||||
|
reply_hit(Req, {error, not_found}, true) ->
|
||||||
|
handler_utils:send_error(Req, 404, <<"not_found">>);
|
||||||
|
reply_hit(Req, {error, invalid_query}, _) ->
|
||||||
|
handler_utils:send_error(Req, 400, <<"invalid_query">>);
|
||||||
|
reply_hit(Req, {error, rate_limited}, _) ->
|
||||||
|
handler_utils:send_error(Req, 429, <<"rate_limited">>);
|
||||||
|
reply_hit(Req, {error, unavailable}, _) ->
|
||||||
|
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
|
||||||
|
|
||||||
|
decode_map(Body) when is_binary(Body) ->
|
||||||
|
try jsx:decode(Body, [return_maps]) of
|
||||||
|
Map when is_map(Map) -> {ok, Map};
|
||||||
|
_ -> error
|
||||||
|
catch
|
||||||
|
_:_ -> error
|
||||||
|
end.
|
||||||
|
|
||||||
|
client_key(Req) ->
|
||||||
|
case cowboy_req:header(<<"x-real-ip">>, Req) of
|
||||||
|
undefined ->
|
||||||
|
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
|
||||||
|
undefined -> peer_ip(Req);
|
||||||
|
<<>> -> peer_ip(Req);
|
||||||
|
Fwd ->
|
||||||
|
case binary:split(Fwd, <<",">>) of
|
||||||
|
[First | _] -> string:trim(First);
|
||||||
|
_ -> peer_ip(Req)
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
<<>> -> peer_ip(Req);
|
||||||
|
IP -> IP
|
||||||
|
end.
|
||||||
|
|
||||||
|
peer_ip(Req) ->
|
||||||
|
case cowboy_req:peer(Req) of
|
||||||
|
{{A, B, C, D}, _} ->
|
||||||
|
iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
|
||||||
|
_ -> <<"unknown">>
|
||||||
|
end.
|
||||||
@@ -32,7 +32,7 @@ trails() ->
|
|||||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
|
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
|
||||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
|
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
|
||||||
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
|
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
|
||||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>},
|
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>, <<"distance">>]}, description => <<"Field to sort by">>},
|
||||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
|
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
|
||||||
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
|
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
|
||||||
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
|
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
|
||||||
@@ -79,7 +79,10 @@ search(Req) ->
|
|||||||
Qs = cowboy_req:parse_qs(Req1),
|
Qs = cowboy_req:parse_qs(Req1),
|
||||||
Type = proplists:get_value(<<"type">>, Qs, undefined),
|
Type = proplists:get_value(<<"type">>, Qs, undefined),
|
||||||
Query = proplists:get_value(<<"q">>, Qs, undefined),
|
Query = proplists:get_value(<<"q">>, Qs, undefined),
|
||||||
Params = parse_params(Qs),
|
case parse_params(Qs) of
|
||||||
|
{error, invalid_radius} ->
|
||||||
|
handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
|
||||||
|
Params ->
|
||||||
case logic_search:search(Type, Query, UserId, Params) of
|
case logic_search:search(Type, Query, UserId, Params) of
|
||||||
{ok, Total, Results} ->
|
{ok, Total, Results} ->
|
||||||
Response = #{
|
Response = #{
|
||||||
@@ -91,6 +94,7 @@ search(Req) ->
|
|||||||
handler_utils:send_json(Req1, 200, Response);
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req1, 500, <<"Search failed">>)
|
handler_utils:send_error(Req1, 500, <<"Search failed">>)
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
{error, Code, Message, Req1} ->
|
{error, Code, Message, Req1} ->
|
||||||
handler_utils:send_error(Req1, Code, Message)
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
@@ -103,7 +107,8 @@ search(Req) ->
|
|||||||
%% @private Собирает карту параметров для поискового движка.
|
%% @private Собирает карту параметров для поискового движка.
|
||||||
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
||||||
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
||||||
-spec parse_params(cowboy_req:qs()) -> map().
|
%% radius без ключа при lat/lon — без отсечения (GEO.md).
|
||||||
|
-spec parse_params(cowboy_req:qs()) -> map() | {error, invalid_radius}.
|
||||||
parse_params(Qs) ->
|
parse_params(Qs) ->
|
||||||
Params0 = #{
|
Params0 = #{
|
||||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||||
@@ -125,12 +130,22 @@ parse_params(Qs) ->
|
|||||||
end,
|
end,
|
||||||
Params1#{sort => Sort, order => Order}
|
Params1#{sort => Sort, order => Order}
|
||||||
end,
|
end,
|
||||||
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||||
{{ok, Lat}, {ok, Lon}} ->
|
{{ok, Lat}, {ok, Lon}} ->
|
||||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
case parse_optional_radius(Qs) of
|
||||||
Params2#{lat => Lat, lon => Lon, radius => Radius};
|
{error, invalid_radius} = E -> E;
|
||||||
_ -> Params2
|
Radius ->
|
||||||
|
Params3 = case Radius of
|
||||||
|
undefined -> Params2#{lat => Lat, lon => Lon};
|
||||||
|
R -> Params2#{lat => Lat, lon => Lon, radius => R}
|
||||||
end,
|
end,
|
||||||
|
with_dates(Qs, Params3)
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
with_dates(Qs, Params2)
|
||||||
|
end.
|
||||||
|
|
||||||
|
with_dates(Qs, Params3) ->
|
||||||
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||||
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
||||||
{{ok, From}, error} -> Params3#{from => From};
|
{{ok, From}, error} -> Params3#{from => From};
|
||||||
@@ -138,6 +153,18 @@ parse_params(Qs) ->
|
|||||||
_ -> Params3
|
_ -> Params3
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
parse_optional_radius(Qs) ->
|
||||||
|
case proplists:get_value(<<"radius">>, Qs) of
|
||||||
|
undefined -> undefined;
|
||||||
|
<<>> -> undefined;
|
||||||
|
Val ->
|
||||||
|
N = handler_utils:parse_int_qs(Val, 0),
|
||||||
|
case N >= 1 andalso N =< 100 of
|
||||||
|
true -> N;
|
||||||
|
false -> {error, invalid_radius}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
||||||
parse_int_param(Qs, Key, Default) ->
|
parse_int_param(Qs, Key, Default) ->
|
||||||
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
|
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
|
||||||
@@ -146,7 +173,18 @@ parse_int_param(Qs, Key, Default) ->
|
|||||||
parse_float_param(Qs, Key) ->
|
parse_float_param(Qs, Key) ->
|
||||||
case proplists:get_value(Key, Qs) of
|
case proplists:get_value(Key, Qs) of
|
||||||
undefined -> error;
|
undefined -> error;
|
||||||
Val -> {ok, binary_to_float(Val)}
|
<<>> -> error;
|
||||||
|
Val ->
|
||||||
|
try binary_to_float(Val) of
|
||||||
|
F -> {ok, F}
|
||||||
|
catch
|
||||||
|
_:_ ->
|
||||||
|
try binary_to_integer(Val) of
|
||||||
|
I -> {ok, float(I)}
|
||||||
|
catch
|
||||||
|
_:_ -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
|
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
|
||||||
|
|||||||
@@ -33,7 +33,10 @@
|
|||||||
is_superadmin/1,
|
is_superadmin/1,
|
||||||
pagination_headers/2,
|
pagination_headers/2,
|
||||||
maybe_report_internal_error/3,
|
maybe_report_internal_error/3,
|
||||||
report_and_send_error/4
|
report_and_send_error/4,
|
||||||
|
parse_location/1,
|
||||||
|
location_to_json/1,
|
||||||
|
meaningful_coords/2
|
||||||
]).
|
]).
|
||||||
-export([admin_to_json/1, audit_to_json/1]).
|
-export([admin_to_json/1, audit_to_json/1]).
|
||||||
|
|
||||||
@@ -317,11 +320,7 @@ audit_reason(_) -> <<>>.
|
|||||||
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
|
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
|
||||||
-spec event_to_json(#event{}) -> map().
|
-spec event_to_json(#event{}) -> map().
|
||||||
event_to_json(Event) ->
|
event_to_json(Event) ->
|
||||||
LocationJson = case Event#event.location of
|
LocationJson = location_to_json(Event#event.location),
|
||||||
undefined -> null;
|
|
||||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
|
||||||
#{address => Addr, lat => Lat, lon => Lon}
|
|
||||||
end,
|
|
||||||
RecurrenceJson = case Event#event.recurrence_rule of
|
RecurrenceJson = case Event#event.recurrence_rule of
|
||||||
undefined -> null;
|
undefined -> null;
|
||||||
Rule -> try jsx:decode(Rule, [return_maps]) of
|
Rule -> try jsx:decode(Rule, [return_maps]) of
|
||||||
@@ -648,3 +647,61 @@ trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
].
|
].
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Location
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
-spec parse_location(term()) -> #location{} | undefined.
|
||||||
|
parse_location(undefined) -> undefined;
|
||||||
|
parse_location(null) -> undefined;
|
||||||
|
parse_location(LocationMap) when is_map(LocationMap) ->
|
||||||
|
Addr0 = maps:get(<<"address">>, LocationMap, maps:get(address, LocationMap, <<>>)),
|
||||||
|
Addr = case Addr0 of
|
||||||
|
A when is_binary(A) -> A;
|
||||||
|
_ -> <<>>
|
||||||
|
end,
|
||||||
|
Lat = to_coord(maps:get(<<"lat">>, LocationMap,
|
||||||
|
maps:get(lat, LocationMap, undefined))),
|
||||||
|
Lon = to_coord(maps:get(<<"lon">>, LocationMap,
|
||||||
|
maps:get(lon, LocationMap, undefined))),
|
||||||
|
Coords = meaningful_coords(Lat, Lon),
|
||||||
|
HasAddr = Addr =/= <<>> andalso Addr =/= <<"undefined">>,
|
||||||
|
case {HasAddr, Coords, Lat =/= undefined orelse Lon =/= undefined} of
|
||||||
|
{false, false, _} -> undefined;
|
||||||
|
{_, true, _} ->
|
||||||
|
#location{address = Addr, lat = Lat, lon = Lon};
|
||||||
|
{true, false, false} ->
|
||||||
|
#location{address = Addr, lat = undefined, lon = undefined};
|
||||||
|
{true, false, true} ->
|
||||||
|
%% одна координата без пары — храним только адрес
|
||||||
|
#location{address = Addr, lat = undefined, lon = undefined};
|
||||||
|
_ -> undefined
|
||||||
|
end;
|
||||||
|
parse_location(_) -> undefined.
|
||||||
|
|
||||||
|
-spec location_to_json(undefined | #location{}) -> null | map().
|
||||||
|
location_to_json(undefined) -> null;
|
||||||
|
location_to_json(#location{address = Addr, lat = Lat, lon = Lon}) ->
|
||||||
|
AddrJ = case Addr of
|
||||||
|
undefined -> null;
|
||||||
|
<<>> -> null;
|
||||||
|
A -> A
|
||||||
|
end,
|
||||||
|
case meaningful_coords(Lat, Lon) of
|
||||||
|
true -> #{address => AddrJ, lat => Lat, lon => Lon};
|
||||||
|
false -> #{address => AddrJ, lat => null, lon => null}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec meaningful_coords(term(), term()) -> boolean().
|
||||||
|
meaningful_coords(Lat, Lon) ->
|
||||||
|
is_number(Lat) andalso is_number(Lon)
|
||||||
|
andalso not (Lat == 0 andalso Lon == 0)
|
||||||
|
andalso Lat >= -90 andalso Lat =< 90
|
||||||
|
andalso Lon >= -180 andalso Lon =< 180.
|
||||||
|
|
||||||
|
to_coord(undefined) -> undefined;
|
||||||
|
to_coord(null) -> undefined;
|
||||||
|
to_coord(N) when is_integer(N) -> float(N);
|
||||||
|
to_coord(N) when is_float(N) -> N;
|
||||||
|
to_coord(_) -> undefined.
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Прокси к self-host Photon: suggest / geocode / reverse.
|
||||||
|
%%% Кэш и rate-limit в ETS. Без PHOTON_URL — {error, unavailable}.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_geo).
|
||||||
|
-export([suggest/3, geocode/3, reverse/4, ensure/0]).
|
||||||
|
-export([format_address/1]).
|
||||||
|
|
||||||
|
-define(CACHE, eventhub_geo_cache).
|
||||||
|
-define(RL, eventhub_geo_rl).
|
||||||
|
-define(MIN_Q, 3).
|
||||||
|
-define(CACHE_TTL_SEC, 604800).
|
||||||
|
-define(RL_WINDOW_SEC, 60).
|
||||||
|
-define(RL_SUGGEST, 40).
|
||||||
|
-define(RL_WRITE, 20).
|
||||||
|
|
||||||
|
-spec ensure() -> ok.
|
||||||
|
ensure() ->
|
||||||
|
case ets:info(?CACHE) of
|
||||||
|
undefined ->
|
||||||
|
ets:new(?CACHE, [named_table, public, set, {read_concurrency, true}]);
|
||||||
|
_ -> ok
|
||||||
|
end,
|
||||||
|
case ets:info(?RL) of
|
||||||
|
undefined ->
|
||||||
|
ets:new(?RL, [named_table, public, set, {write_concurrency, true}]);
|
||||||
|
_ -> ok
|
||||||
|
end,
|
||||||
|
ok.
|
||||||
|
|
||||||
|
-spec suggest(binary(), binary(), binary()) ->
|
||||||
|
{ok, [map()]} | {error, invalid_query | rate_limited | unavailable}.
|
||||||
|
suggest(Q, Lang, RlKey) ->
|
||||||
|
ensure(),
|
||||||
|
case normalize_q(Q) of
|
||||||
|
{error, invalid_query} = E -> E;
|
||||||
|
{ok, Qn} ->
|
||||||
|
case allow(RlKey, ?RL_SUGGEST) of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
LangN = lang(Lang),
|
||||||
|
CacheKey = {suggest, Qn, LangN},
|
||||||
|
case cache_get(CacheKey) of
|
||||||
|
{ok, Hits} -> {ok, Hits};
|
||||||
|
miss ->
|
||||||
|
case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
|
||||||
|
{<<"limit">>, <<"8">>}]) of
|
||||||
|
{ok, Body} ->
|
||||||
|
Hits = features_to_hits(Body),
|
||||||
|
cache_put(CacheKey, Hits),
|
||||||
|
{ok, Hits};
|
||||||
|
{error, _} -> {error, unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec geocode(binary(), binary(), binary()) ->
|
||||||
|
{ok, map()} | {error, invalid_query | not_found | rate_limited | unavailable}.
|
||||||
|
geocode(Q, Lang, RlKey) ->
|
||||||
|
ensure(),
|
||||||
|
case normalize_q(Q) of
|
||||||
|
{error, invalid_query} = E -> E;
|
||||||
|
{ok, Qn} ->
|
||||||
|
case allow(RlKey, ?RL_WRITE) of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
LangN = lang(Lang),
|
||||||
|
CacheKey = {geocode, Qn, LangN},
|
||||||
|
case cache_get(CacheKey) of
|
||||||
|
{ok, Hit} -> {ok, Hit};
|
||||||
|
miss ->
|
||||||
|
case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
|
||||||
|
{<<"limit">>, <<"1">>}]) of
|
||||||
|
{ok, Body} ->
|
||||||
|
case features_to_hits(Body) of
|
||||||
|
[Hit | _] ->
|
||||||
|
Out = Hit#{<<"source">> => <<"photon">>},
|
||||||
|
cache_put(CacheKey, Out),
|
||||||
|
{ok, Out};
|
||||||
|
[] -> {error, not_found}
|
||||||
|
end;
|
||||||
|
{error, _} -> {error, unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec reverse(number(), number(), binary(), binary()) ->
|
||||||
|
{ok, map()} | {error, invalid_query | rate_limited | unavailable}.
|
||||||
|
reverse(Lat, Lon, Lang, RlKey) when is_number(Lat), is_number(Lon) ->
|
||||||
|
ensure(),
|
||||||
|
case {valid_lat(Lat), valid_lon(Lon)} of
|
||||||
|
{true, true} ->
|
||||||
|
case allow(RlKey, ?RL_WRITE) of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
LangN = lang(Lang),
|
||||||
|
LatR = round_coord(Lat),
|
||||||
|
LonR = round_coord(Lon),
|
||||||
|
CacheKey = {reverse, LatR, LonR, LangN},
|
||||||
|
case cache_get(CacheKey) of
|
||||||
|
{ok, Hit} -> {ok, Hit};
|
||||||
|
miss ->
|
||||||
|
LatB = float_to_bin(LatR),
|
||||||
|
LonB = float_to_bin(LonR),
|
||||||
|
case photon_get(<<"/reverse">>, [{<<"lat">>, LatB}, {<<"lon">>, LonB},
|
||||||
|
{<<"lang">>, LangN}]) of
|
||||||
|
{ok, Body} ->
|
||||||
|
Hit = case features_to_hits(Body) of
|
||||||
|
[H | _] -> H#{<<"source">> => <<"photon">>};
|
||||||
|
[] ->
|
||||||
|
#{<<"address">> => fallback_addr(LatR, LonR),
|
||||||
|
<<"lat">> => LatR,
|
||||||
|
<<"lon">> => LonR,
|
||||||
|
<<"source">> => <<"photon">>}
|
||||||
|
end,
|
||||||
|
cache_put(CacheKey, Hit),
|
||||||
|
{ok, Hit};
|
||||||
|
{error, _} -> {error, unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
_ -> {error, invalid_query}
|
||||||
|
end;
|
||||||
|
reverse(_, _, _, _) ->
|
||||||
|
{error, invalid_query}.
|
||||||
|
|
||||||
|
%% ——— internals ———
|
||||||
|
|
||||||
|
normalize_q(Q) when is_binary(Q) ->
|
||||||
|
T = string:trim(Q),
|
||||||
|
case string:length(T) >= ?MIN_Q of
|
||||||
|
true -> {ok, T};
|
||||||
|
false -> {error, invalid_query}
|
||||||
|
end;
|
||||||
|
normalize_q(_) ->
|
||||||
|
{error, invalid_query}.
|
||||||
|
|
||||||
|
lang(<<>>) -> <<"ru">>;
|
||||||
|
lang(undefined) -> <<"ru">>;
|
||||||
|
lang(L) when is_binary(L), byte_size(L) >= 2 -> binary:part(L, 0, 2);
|
||||||
|
lang(_) -> <<"ru">>.
|
||||||
|
|
||||||
|
valid_lat(Lat) -> is_number(Lat) andalso Lat >= -90 andalso Lat =< 90.
|
||||||
|
valid_lon(Lon) -> is_number(Lon) andalso Lon >= -180 andalso Lon =< 180.
|
||||||
|
|
||||||
|
round_coord(N) when is_integer(N) -> float(N);
|
||||||
|
round_coord(N) when is_float(N) -> round(N * 100000) / 100000.
|
||||||
|
|
||||||
|
float_to_bin(N) when is_integer(N) -> integer_to_binary(N);
|
||||||
|
float_to_bin(N) when is_float(N) ->
|
||||||
|
iolist_to_binary(io_lib:format("~.6f", [N])).
|
||||||
|
|
||||||
|
fallback_addr(Lat, Lon) ->
|
||||||
|
iolist_to_binary(io_lib:format("~.5f, ~.5f", [float(Lat), float(Lon)])).
|
||||||
|
|
||||||
|
allow(Key, Limit) ->
|
||||||
|
Now = erlang:monotonic_time(second),
|
||||||
|
case ets:lookup(?RL, Key) of
|
||||||
|
[{Key, Count, Start}] when Now - Start < ?RL_WINDOW_SEC ->
|
||||||
|
case Count < Limit of
|
||||||
|
true ->
|
||||||
|
ets:update_counter(?RL, Key, {2, 1}),
|
||||||
|
true;
|
||||||
|
false -> false
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ets:insert(?RL, {Key, 1, Now}),
|
||||||
|
true
|
||||||
|
end.
|
||||||
|
|
||||||
|
cache_get(Key) ->
|
||||||
|
Now = erlang:system_time(second),
|
||||||
|
case ets:lookup(?CACHE, Key) of
|
||||||
|
[{Key, Exp, Val}] when Exp > Now -> {ok, Val};
|
||||||
|
[_] -> ets:delete(?CACHE, Key), miss;
|
||||||
|
[] -> miss
|
||||||
|
end.
|
||||||
|
|
||||||
|
cache_put(Key, Val) ->
|
||||||
|
Exp = erlang:system_time(second) + ?CACHE_TTL_SEC,
|
||||||
|
ets:insert(?CACHE, {Key, Exp, Val}),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
photon_url() ->
|
||||||
|
case os:getenv("PHOTON_URL") of
|
||||||
|
false -> <<>>;
|
||||||
|
"" -> <<>>;
|
||||||
|
Url -> list_to_binary(string:trim(Url))
|
||||||
|
end.
|
||||||
|
|
||||||
|
timeout_ms() ->
|
||||||
|
case os:getenv("PHOTON_TIMEOUT_MS") of
|
||||||
|
false -> 3000;
|
||||||
|
"" -> 3000;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N > 0 -> N;
|
||||||
|
_ -> 3000
|
||||||
|
catch
|
||||||
|
_:_ -> 3000
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
photon_get(Path, Qs) ->
|
||||||
|
case photon_url() of
|
||||||
|
<<>> -> {error, unavailable};
|
||||||
|
Base0 ->
|
||||||
|
Base = strip_slash(Base0),
|
||||||
|
Query = uri_string:compose_query(Qs),
|
||||||
|
Url = binary_to_list(<<Base/binary, Path/binary, $?, Query/binary>>),
|
||||||
|
http_get(Url)
|
||||||
|
end.
|
||||||
|
|
||||||
|
strip_slash(B) ->
|
||||||
|
case binary:last(B) of
|
||||||
|
$/ -> binary:part(B, 0, byte_size(B) - 1);
|
||||||
|
_ -> B
|
||||||
|
end.
|
||||||
|
|
||||||
|
http_get(Url) ->
|
||||||
|
Fun = case application:get_env(eventhub, geo_http_get) of
|
||||||
|
{ok, F} when is_function(F, 1) -> F;
|
||||||
|
_ -> fun default_http_get/1
|
||||||
|
end,
|
||||||
|
Fun(Url).
|
||||||
|
|
||||||
|
default_http_get(Url) ->
|
||||||
|
_ = application:ensure_all_started(inets),
|
||||||
|
Headers = [{"user-agent", "EventHub/1.0 (geo; +https://calentiq.com)"}],
|
||||||
|
HttpOpts = [{timeout, timeout_ms()}],
|
||||||
|
case httpc:request(get, {Url, Headers}, HttpOpts, [{body_format, binary}]) of
|
||||||
|
{ok, {{_, Code, _}, _, Body}} when Code >= 200, Code < 300, is_binary(Body) ->
|
||||||
|
{ok, Body};
|
||||||
|
{ok, {{_, Code, _}, _, _}} ->
|
||||||
|
{error, {http_status, Code}};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
features_to_hits(Body) when is_binary(Body) ->
|
||||||
|
try jsx:decode(Body, [return_maps]) of
|
||||||
|
Map when is_map(Map) ->
|
||||||
|
Feats = maps:get(<<"features">>, Map, []),
|
||||||
|
lists:filtermap(fun feature_to_hit/1, Feats);
|
||||||
|
_ -> []
|
||||||
|
catch
|
||||||
|
_:_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
feature_to_hit(#{<<"geometry">> := #{<<"coordinates">> := [Lon, Lat | _]}} = F) ->
|
||||||
|
Props = maps:get(<<"properties">>, F, #{}),
|
||||||
|
Addr = format_address(Props),
|
||||||
|
{true, #{
|
||||||
|
<<"address">> => Addr,
|
||||||
|
<<"lat">> => to_num(Lat),
|
||||||
|
<<"lon">> => to_num(Lon)
|
||||||
|
}};
|
||||||
|
feature_to_hit(_) ->
|
||||||
|
false.
|
||||||
|
|
||||||
|
to_num(N) when is_integer(N) -> float(N);
|
||||||
|
to_num(N) when is_float(N) -> N;
|
||||||
|
to_num(_) -> 0.0.
|
||||||
|
|
||||||
|
-spec format_address(map()) -> binary().
|
||||||
|
format_address(Props) when is_map(Props) ->
|
||||||
|
Name = bin(maps:get(<<"name">>, Props, <<>>)),
|
||||||
|
House = bin(maps:get(<<"housenumber">>, Props, <<>>)),
|
||||||
|
Street = bin(maps:get(<<"street">>, Props, <<>>)),
|
||||||
|
StreetLine = case {House, Street} of
|
||||||
|
{<<>>, S} -> S;
|
||||||
|
{H, <<>>} -> H;
|
||||||
|
{H, S} -> <<H/binary, " ", S/binary>>
|
||||||
|
end,
|
||||||
|
City = first_nonempty([
|
||||||
|
maps:get(<<"city">>, Props, <<>>),
|
||||||
|
maps:get(<<"town">>, Props, <<>>),
|
||||||
|
maps:get(<<"village">>, Props, <<>>),
|
||||||
|
maps:get(<<"locality">>, Props, <<>>)
|
||||||
|
]),
|
||||||
|
Country = bin(maps:get(<<"country">>, Props, <<>>)),
|
||||||
|
Parts0 = [Name, StreetLine, City, Country],
|
||||||
|
Parts = [P || P <- Parts0, P =/= <<>>],
|
||||||
|
Unique = unique_keep(Parts),
|
||||||
|
case Unique of
|
||||||
|
[] -> <<"—">>;
|
||||||
|
_ -> join_comma(Unique)
|
||||||
|
end.
|
||||||
|
|
||||||
|
bin(B) when is_binary(B) -> string:trim(B);
|
||||||
|
bin(I) when is_integer(I) -> integer_to_binary(I);
|
||||||
|
bin(_) -> <<>>.
|
||||||
|
|
||||||
|
first_nonempty([]) -> <<>>;
|
||||||
|
first_nonempty([H | T]) ->
|
||||||
|
B = bin(H),
|
||||||
|
case B of
|
||||||
|
<<>> -> first_nonempty(T);
|
||||||
|
_ -> B
|
||||||
|
end.
|
||||||
|
|
||||||
|
unique_keep(List) ->
|
||||||
|
lists:reverse(lists:foldl(fun(X, Acc) ->
|
||||||
|
case lists:member(X, Acc) of
|
||||||
|
true -> Acc;
|
||||||
|
false -> [X | Acc]
|
||||||
|
end
|
||||||
|
end, [], List)).
|
||||||
|
|
||||||
|
join_comma([H]) -> H;
|
||||||
|
join_comma([H | T]) ->
|
||||||
|
Rest = join_comma(T),
|
||||||
|
<<H/binary, ", ", Rest/binary>>.
|
||||||
+145
-36
@@ -98,7 +98,7 @@ discovery_events(UserId, Params, Limit, Offset) ->
|
|||||||
search_events(undefined, UserId, Params, Limit, Offset);
|
search_events(undefined, UserId, Params, Limit, Offset);
|
||||||
Items ->
|
Items ->
|
||||||
Total = length(Items),
|
Total = length(Items),
|
||||||
{ok, Total, format_events(paginate(Items, Limit, Offset))}
|
{ok, Total, format_events(paginate(Items, Limit, Offset), Params)}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
discovery_calendars(UserId, Params, Limit, Offset) ->
|
discovery_calendars(UserId, Params, Limit, Offset) ->
|
||||||
@@ -110,7 +110,7 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
|
|||||||
search_calendars(undefined, UserId, Params, Limit, Offset);
|
search_calendars(undefined, UserId, Params, Limit, Offset);
|
||||||
Items ->
|
Items ->
|
||||||
Total = length(Items),
|
Total = length(Items),
|
||||||
{ok, Total, format_calendars(paginate(Items, Limit, Offset))}
|
{ok, Total, format_calendars(paginate(Items, Limit, Offset), Params)}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%% ============ Поиск событий ============
|
%% ============ Поиск событий ============
|
||||||
@@ -131,7 +131,7 @@ search_events(Query, UserId, Params, Limit, Offset) ->
|
|||||||
%% Pagination is applied BEFORE formatting so only the page slice is
|
%% Pagination is applied BEFORE formatting so only the page slice is
|
||||||
%% enriched with calendar data (see format_events).
|
%% enriched with calendar data (see format_events).
|
||||||
Paginated = paginate(Sorted, Limit, Offset),
|
Paginated = paginate(Sorted, Limit, Offset),
|
||||||
{ok, length(Filtered), format_events(Paginated)}.
|
{ok, length(Filtered), format_events(Paginated, Params)}.
|
||||||
|
|
||||||
%% ============ Поиск календарей ============
|
%% ============ Поиск календарей ============
|
||||||
|
|
||||||
@@ -145,8 +145,9 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
|
|||||||
AllCalendars = get_all_calendars(),
|
AllCalendars = get_all_calendars(),
|
||||||
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
|
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
|
||||||
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
|
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
|
||||||
Paginated = paginate(Filtered, Limit, Offset),
|
Sorted = sort_calendars(Filtered, Params),
|
||||||
{ok, length(Filtered), format_calendars(Paginated)}.
|
Paginated = paginate(Sorted, Limit, Offset),
|
||||||
|
{ok, length(Filtered), format_calendars(Paginated, Params)}.
|
||||||
|
|
||||||
%% ============ Получение данных ============
|
%% ============ Получение данных ============
|
||||||
|
|
||||||
@@ -223,7 +224,8 @@ apply_event_filters(Events, Query, Params) ->
|
|||||||
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
|
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
|
||||||
apply_calendar_filters(Calendars, Query, Params) ->
|
apply_calendar_filters(Calendars, Query, Params) ->
|
||||||
Calendars1 = filter_by_text(Calendars, Query),
|
Calendars1 = filter_by_text(Calendars, Query),
|
||||||
filter_by_tags(Calendars1, Params).
|
Calendars2 = filter_by_tags(Calendars1, Params),
|
||||||
|
filter_calendars_by_location(Calendars2, Params).
|
||||||
|
|
||||||
%% --- Текстовый поиск ---
|
%% --- Текстовый поиск ---
|
||||||
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
|
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
|
||||||
@@ -269,21 +271,63 @@ filter_by_date_range(Events, Params) ->
|
|||||||
%% --- Гео-фильтр ---
|
%% --- Гео-фильтр ---
|
||||||
-spec filter_by_location([#event{}], map()) -> [#event{}].
|
-spec filter_by_location([#event{}], map()) -> [#event{}].
|
||||||
filter_by_location(Events, Params) ->
|
filter_by_location(Events, Params) ->
|
||||||
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
|
case geo_origin(Params) of
|
||||||
{undefined, _} -> Events;
|
none -> Events;
|
||||||
{_, undefined} -> Events;
|
|
||||||
{Lat, Lon} ->
|
{Lat, Lon} ->
|
||||||
Radius = maps:get(radius, Params, 10),
|
Radius = maps:get(radius, Params, undefined),
|
||||||
lists:filter(fun(Event) ->
|
lists:filter(fun(Event) ->
|
||||||
case Event#event.location of
|
within_radius(event_coords(Event), Lat, Lon, Radius)
|
||||||
#location{lat = EventLat, lon = EventLon}
|
|
||||||
when is_number(EventLat), is_number(EventLon) ->
|
|
||||||
distance(Lat, Lon, EventLat, EventLon) =< Radius;
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end, Events)
|
end, Events)
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
-spec filter_calendars_by_location([#calendar{}], map()) -> [#calendar{}].
|
||||||
|
filter_calendars_by_location(Calendars, Params) ->
|
||||||
|
case geo_origin(Params) of
|
||||||
|
none -> Calendars;
|
||||||
|
{Lat, Lon} ->
|
||||||
|
Radius = maps:get(radius, Params, undefined),
|
||||||
|
lists:filter(fun(Cal) ->
|
||||||
|
within_radius(calendar_coords(Cal), Lat, Lon, Radius)
|
||||||
|
end, Calendars)
|
||||||
|
end.
|
||||||
|
|
||||||
|
geo_origin(Params) ->
|
||||||
|
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
|
||||||
|
{Lat, Lon} when is_number(Lat), is_number(Lon) -> {Lat, Lon};
|
||||||
|
_ -> none
|
||||||
|
end.
|
||||||
|
|
||||||
|
within_radius(none, _, _, _) -> false;
|
||||||
|
within_radius({ok, ELat, ELon}, Lat, Lon, Radius) ->
|
||||||
|
D = distance(Lat, Lon, ELat, ELon),
|
||||||
|
Radius =:= undefined orelse D =< Radius.
|
||||||
|
|
||||||
|
event_coords(#event{location = Loc}) -> loc_coords(Loc);
|
||||||
|
event_coords(_) -> none.
|
||||||
|
|
||||||
|
calendar_coords(#calendar{settings = Settings}) when is_map(Settings) ->
|
||||||
|
Loc = maps:get(<<"default_location">>, Settings,
|
||||||
|
maps:get(default_location, Settings, undefined)),
|
||||||
|
loc_coords(Loc);
|
||||||
|
calendar_coords(_) -> none.
|
||||||
|
|
||||||
|
loc_coords(#location{lat = Lat, lon = Lon}) ->
|
||||||
|
case handler_utils:meaningful_coords(Lat, Lon) of
|
||||||
|
true -> {ok, to_float(Lat), to_float(Lon)};
|
||||||
|
false -> none
|
||||||
|
end;
|
||||||
|
loc_coords(Loc) when is_map(Loc) ->
|
||||||
|
Lat = maps:get(<<"lat">>, Loc, maps:get(lat, Loc, undefined)),
|
||||||
|
Lon = maps:get(<<"lon">>, Loc, maps:get(lon, Loc, undefined)),
|
||||||
|
case handler_utils:meaningful_coords(Lat, Lon) of
|
||||||
|
true -> {ok, to_float(Lat), to_float(Lon)};
|
||||||
|
false -> none
|
||||||
|
end;
|
||||||
|
loc_coords(_) -> none.
|
||||||
|
|
||||||
|
to_float(N) when is_integer(N) -> float(N);
|
||||||
|
to_float(N) when is_float(N) -> N.
|
||||||
|
|
||||||
%% ============ Вспомогательные функции ============
|
%% ============ Вспомогательные функции ============
|
||||||
|
|
||||||
-spec get_title(#event{} | #calendar{}) -> binary().
|
-spec get_title(#event{} | #calendar{}) -> binary().
|
||||||
@@ -322,19 +366,66 @@ deg_to_rad(Deg) -> Deg * math:pi() / 180.
|
|||||||
|
|
||||||
-spec sort_events([#event{}], map()) -> [#event{}].
|
-spec sort_events([#event{}], map()) -> [#event{}].
|
||||||
sort_events(Events, Params) ->
|
sort_events(Events, Params) ->
|
||||||
SortBy = maps:get(sort, Params, <<"start_time">>),
|
Default = case geo_origin(Params) of
|
||||||
|
none -> <<"start_time">>;
|
||||||
|
_ -> <<"distance">>
|
||||||
|
end,
|
||||||
|
SortBy = maps:get(sort, Params, Default),
|
||||||
Order = maps:get(order, Params, <<"asc">>),
|
Order = maps:get(order, Params, <<"asc">>),
|
||||||
Sorted = case SortBy of
|
Sorted = case SortBy of
|
||||||
|
<<"distance">> -> sort_by_distance_events(Events, Params);
|
||||||
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
|
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
|
||||||
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
|
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
|
||||||
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
|
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
|
||||||
|
<<"title">> -> lists:sort(fun(A, B) -> A#event.title =< B#event.title end, Events);
|
||||||
_ -> Events
|
_ -> Events
|
||||||
end,
|
end,
|
||||||
|
case {SortBy, Order} of
|
||||||
|
{<<"distance">>, <<"desc">>} -> lists:reverse(Sorted);
|
||||||
|
{<<"rating">>, _} -> Sorted;
|
||||||
|
{_, <<"desc">>} -> lists:reverse(Sorted);
|
||||||
|
_ -> Sorted
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_calendars(Calendars, Params) ->
|
||||||
|
Default = case geo_origin(Params) of
|
||||||
|
none -> <<"title">>;
|
||||||
|
_ -> <<"distance">>
|
||||||
|
end,
|
||||||
|
SortBy = maps:get(sort, Params, Default),
|
||||||
|
Order = maps:get(order, Params, <<"asc">>),
|
||||||
|
Sorted = case SortBy of
|
||||||
|
<<"distance">> -> sort_by_distance_calendars(Calendars, Params);
|
||||||
|
<<"created_at">> -> lists:sort(fun(A, B) -> A#calendar.created_at =< B#calendar.created_at end, Calendars);
|
||||||
|
<<"title">> -> lists:sort(fun(A, B) -> A#calendar.title =< B#calendar.title end, Calendars);
|
||||||
|
_ -> Calendars
|
||||||
|
end,
|
||||||
case Order of
|
case Order of
|
||||||
<<"desc">> -> lists:reverse(Sorted);
|
<<"desc">> -> lists:reverse(Sorted);
|
||||||
_ -> Sorted
|
_ -> Sorted
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
sort_by_distance_events(Events, Params) ->
|
||||||
|
case geo_origin(Params) of
|
||||||
|
none -> Events;
|
||||||
|
{Lat, Lon} ->
|
||||||
|
lists:sort(fun(A, B) ->
|
||||||
|
dist_or_inf(event_coords(A), Lat, Lon) =< dist_or_inf(event_coords(B), Lat, Lon)
|
||||||
|
end, Events)
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_by_distance_calendars(Cals, Params) ->
|
||||||
|
case geo_origin(Params) of
|
||||||
|
none -> Cals;
|
||||||
|
{Lat, Lon} ->
|
||||||
|
lists:sort(fun(A, B) ->
|
||||||
|
dist_or_inf(calendar_coords(A), Lat, Lon) =< dist_or_inf(calendar_coords(B), Lat, Lon)
|
||||||
|
end, Cals)
|
||||||
|
end.
|
||||||
|
|
||||||
|
dist_or_inf({ok, ELat, ELon}, Lat, Lon) -> distance(Lat, Lon, ELat, ELon);
|
||||||
|
dist_or_inf(none, _, _) -> 1.0e12.
|
||||||
|
|
||||||
%% ============ Пагинация ============
|
%% ============ Пагинация ============
|
||||||
|
|
||||||
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
|
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
|
||||||
@@ -343,28 +434,24 @@ paginate(List, Limit, Offset) ->
|
|||||||
|
|
||||||
%% ============ Форматирование ответа ============
|
%% ============ Форматирование ответа ============
|
||||||
|
|
||||||
-spec format_events([#event{}]) -> [map()].
|
-spec format_events([#event{}], map()) -> [map()].
|
||||||
format_events(Events) ->
|
format_events(Events, Params) ->
|
||||||
%% Optimization: batch-fetch calendars for the (already paginated)
|
%% Optimization: batch-fetch calendars for the (already paginated)
|
||||||
%% subset only, avoiding an N+1 query per event during formatting.
|
%% subset only, avoiding an N+1 query per event during formatting.
|
||||||
CalendarMap = build_calendar_map(
|
CalendarMap = build_calendar_map(
|
||||||
lists:usort([E#event.calendar_id || E <- Events])),
|
lists:usort([E#event.calendar_id || E <- Events])),
|
||||||
lists:map(fun(E) -> format_event(E, CalendarMap) end, Events).
|
lists:map(fun(E) -> format_event(E, CalendarMap, Params) end, Events).
|
||||||
|
|
||||||
-spec format_event(#event{}, #{binary() => #calendar{}}) -> map().
|
-spec format_event(#event{}, #{binary() => #calendar{}}, map()) -> map().
|
||||||
format_event(Event, CalendarMap) ->
|
format_event(Event, CalendarMap, Params) ->
|
||||||
Location = case Event#event.location of
|
Location = handler_utils:location_to_json(Event#event.location),
|
||||||
undefined -> null;
|
|
||||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
|
||||||
#{address => Addr, lat => Lat, lon => Lon}
|
|
||||||
end,
|
|
||||||
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
|
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
|
||||||
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
|
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
|
||||||
{ok, #calendar{title = T, image_url = undefined}} -> {T, null};
|
{ok, #calendar{title = T, image_url = undefined}} -> {T, null};
|
||||||
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
|
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
|
||||||
error -> {null, null}
|
error -> {null, null}
|
||||||
end,
|
end,
|
||||||
#{
|
Base = #{
|
||||||
id => Event#event.id,
|
id => Event#event.id,
|
||||||
calendar_id => Event#event.calendar_id,
|
calendar_id => Event#event.calendar_id,
|
||||||
calendar_title => CalendarTitle,
|
calendar_title => CalendarTitle,
|
||||||
@@ -380,15 +467,25 @@ format_event(Event, CalendarMap) ->
|
|||||||
rating_count => Event#event.rating_count,
|
rating_count => Event#event.rating_count,
|
||||||
status => Event#event.status,
|
status => Event#event.status,
|
||||||
image_url => ImageUrl
|
image_url => ImageUrl
|
||||||
}.
|
},
|
||||||
|
maybe_distance(Base, event_coords(Event), Params).
|
||||||
|
|
||||||
-spec format_calendars([#calendar{}]) -> [map()].
|
-spec format_calendars([#calendar{}], map()) -> [map()].
|
||||||
format_calendars(Calendars) ->
|
format_calendars(Calendars, Params) ->
|
||||||
lists:map(fun format_calendar/1, Calendars).
|
lists:map(fun(C) -> format_calendar(C, Params) end, Calendars).
|
||||||
|
|
||||||
-spec format_calendar(#calendar{}) -> map().
|
-spec format_calendar(#calendar{}, map()) -> map().
|
||||||
format_calendar(Calendar) ->
|
format_calendar(Calendar, Params) ->
|
||||||
#{
|
Settings = case Calendar#calendar.settings of
|
||||||
|
M when is_map(M) -> M;
|
||||||
|
_ -> #{}
|
||||||
|
end,
|
||||||
|
LocJson = case maps:get(<<"default_location">>, Settings,
|
||||||
|
maps:get(default_location, Settings, undefined)) of
|
||||||
|
undefined -> null;
|
||||||
|
Loc -> Loc
|
||||||
|
end,
|
||||||
|
Base = #{
|
||||||
id => Calendar#calendar.id,
|
id => Calendar#calendar.id,
|
||||||
owner_id => Calendar#calendar.owner_id,
|
owner_id => Calendar#calendar.owner_id,
|
||||||
title => Calendar#calendar.title,
|
title => Calendar#calendar.title,
|
||||||
@@ -404,7 +501,19 @@ format_calendar(Calendar) ->
|
|||||||
rating_avg => Calendar#calendar.rating_avg,
|
rating_avg => Calendar#calendar.rating_avg,
|
||||||
rating_count => Calendar#calendar.rating_count,
|
rating_count => Calendar#calendar.rating_count,
|
||||||
status => Calendar#calendar.status
|
status => Calendar#calendar.status
|
||||||
}.
|
},
|
||||||
|
Base1 = case geo_origin(Params) of
|
||||||
|
none -> Base;
|
||||||
|
_ -> Base#{default_location => LocJson}
|
||||||
|
end,
|
||||||
|
maybe_distance(Base1, calendar_coords(Calendar), Params).
|
||||||
|
|
||||||
|
maybe_distance(Map, Coords, Params) ->
|
||||||
|
case {geo_origin(Params), Coords} of
|
||||||
|
{{OLat, OLon}, {ok, Lat, Lon}} ->
|
||||||
|
Map#{distance_km => round(distance(OLat, OLon, Lat, Lon) * 10) / 10};
|
||||||
|
_ -> Map
|
||||||
|
end.
|
||||||
|
|
||||||
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
|
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
|
||||||
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
|
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ user() ->
|
|||||||
handler_review_vote,
|
handler_review_vote,
|
||||||
handler_reviews,
|
handler_reviews,
|
||||||
handler_search,
|
handler_search,
|
||||||
|
handler_geo,
|
||||||
handler_subscription,
|
handler_subscription,
|
||||||
handler_ticket_by_id,
|
handler_ticket_by_id,
|
||||||
handler_tickets,
|
handler_tickets,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-module(logic_geo_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
setup() ->
|
||||||
|
catch ets:delete(eventhub_geo_cache),
|
||||||
|
catch ets:delete(eventhub_geo_rl),
|
||||||
|
logic_geo:ensure(),
|
||||||
|
application:unset_env(eventhub, geo_http_get),
|
||||||
|
os:putenv("PHOTON_URL", "http://photon.test"),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
cleanup(_) ->
|
||||||
|
application:unset_env(eventhub, geo_http_get),
|
||||||
|
os:unsetenv("PHOTON_URL"),
|
||||||
|
catch ets:delete(eventhub_geo_cache),
|
||||||
|
catch ets:delete(eventhub_geo_rl),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
logic_geo_test_() ->
|
||||||
|
{foreach,
|
||||||
|
fun setup/0,
|
||||||
|
fun cleanup/1,
|
||||||
|
[
|
||||||
|
{"Suggest too short", fun test_suggest_short/0},
|
||||||
|
{"Suggest parses photon geojson", fun test_suggest_ok/0},
|
||||||
|
{"Geocode empty features is not_found", fun test_geocode_not_found/0},
|
||||||
|
{"Unavailable without photon url", fun test_unavailable/0},
|
||||||
|
{"Reverse fallback address", fun test_reverse_empty/0},
|
||||||
|
{"Format address from properties", fun test_format_address/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
sample_geojson() ->
|
||||||
|
<<"{
|
||||||
|
\"features\": [{
|
||||||
|
\"geometry\": {\"coordinates\": [37.62, 55.75]},
|
||||||
|
\"properties\": {
|
||||||
|
\"name\": \"Red Square\",
|
||||||
|
\"city\": \"Moscow\",
|
||||||
|
\"country\": \"Russia\"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}">>.
|
||||||
|
|
||||||
|
test_suggest_short() ->
|
||||||
|
?assertEqual({error, invalid_query}, logic_geo:suggest(<<"ab">>, <<"ru">>, <<"ip">>)).
|
||||||
|
|
||||||
|
test_suggest_ok() ->
|
||||||
|
application:set_env(eventhub, geo_http_get, fun(_) -> {ok, sample_geojson()} end),
|
||||||
|
{ok, Hits} = logic_geo:suggest(<<"красная площадь">>, <<"ru">>, <<"ip1">>),
|
||||||
|
?assertMatch([#{<<"lat">> := 55.75, <<"lon">> := 37.62}], Hits),
|
||||||
|
[#{<<"address">> := Addr}] = Hits,
|
||||||
|
?assertNotEqual(<<>>, Addr).
|
||||||
|
|
||||||
|
test_geocode_not_found() ->
|
||||||
|
application:set_env(eventhub, geo_http_get, fun(_) -> {ok, <<"{\"features\":[]}">>} end),
|
||||||
|
?assertEqual({error, not_found},
|
||||||
|
logic_geo:geocode(<<"nowherexyz">>, <<"ru">>, <<"u1">>)).
|
||||||
|
|
||||||
|
test_unavailable() ->
|
||||||
|
os:unsetenv("PHOTON_URL"),
|
||||||
|
application:unset_env(eventhub, geo_http_get),
|
||||||
|
?assertEqual({error, unavailable},
|
||||||
|
logic_geo:suggest(<<"москва центр">>, <<"ru">>, <<"ip2">>)).
|
||||||
|
|
||||||
|
test_reverse_empty() ->
|
||||||
|
application:set_env(eventhub, geo_http_get, fun(_) -> {ok, <<"{\"features\":[]}">>} end),
|
||||||
|
{ok, Hit} = logic_geo:reverse(55.75, 37.62, <<"ru">>, <<"u2">>),
|
||||||
|
?assertEqual(<<"photon">>, maps:get(<<"source">>, Hit)),
|
||||||
|
?assertEqual(55.75, maps:get(<<"lat">>, Hit)).
|
||||||
|
|
||||||
|
test_format_address() ->
|
||||||
|
Addr = logic_geo:format_address(#{
|
||||||
|
<<"housenumber">> => <<"1">>,
|
||||||
|
<<"street">> => <<"Tverskaya">>,
|
||||||
|
<<"city">> => <<"Moscow">>,
|
||||||
|
<<"country">> => <<"Russia">>
|
||||||
|
}),
|
||||||
|
?assertEqual(<<"1 Tverskaya, Moscow, Russia">>, Addr).
|
||||||
@@ -23,6 +23,9 @@ logic_search_test_() ->
|
|||||||
{"Search events by tags", fun test_search_events_by_tags/0},
|
{"Search events by tags", fun test_search_events_by_tags/0},
|
||||||
{"Search events by date range", fun test_search_events_by_date/0},
|
{"Search events by date range", fun test_search_events_by_date/0},
|
||||||
{"Search events by location", fun test_search_events_by_location/0},
|
{"Search events by location", fun test_search_events_by_location/0},
|
||||||
|
{"Search calendars by location", fun test_search_calendars_by_location/0},
|
||||||
|
{"Geo without radius keeps distant hits", fun test_geo_no_radius/0},
|
||||||
|
{"Geo sort by distance", fun test_geo_sort_distance/0},
|
||||||
{"Combined search", fun test_combined_search/0},
|
{"Combined search", fun test_combined_search/0},
|
||||||
{"Search calendars", fun test_search_calendars/0},
|
{"Search calendars", fun test_search_calendars/0},
|
||||||
{"Search calendars include image_url", fun test_search_calendars_image_url/0},
|
{"Search calendars include image_url", fun test_search_calendars_image_url/0},
|
||||||
@@ -152,6 +155,59 @@ test_search_events_by_location() ->
|
|||||||
#{lat => 59.9343, lon => 30.3351, radius => 10})),
|
#{lat => 59.9343, lon => 30.3351, radius => 10})),
|
||||||
?assertEqual(1, Total2).
|
?assertEqual(1, Total2).
|
||||||
|
|
||||||
|
test_search_calendars_by_location() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
NearId = create_test_calendar(OwnerId, commercial, []),
|
||||||
|
FarId = create_test_calendar(OwnerId, commercial, []),
|
||||||
|
set_default_location(NearId, <<"Moscow">>, 55.7558, 37.6173),
|
||||||
|
set_default_location(FarId, <<"SPb">>, 59.9343, 30.3351),
|
||||||
|
{Total, Hits} = calendars_from(logic_search:search(<<"calendar">>, undefined, OwnerId,
|
||||||
|
#{lat => 55.7558, lon => 37.6173, radius => 10})),
|
||||||
|
?assertEqual(1, Total),
|
||||||
|
[Hit] = Hits,
|
||||||
|
?assertEqual(NearId, maps:get(id, Hit)),
|
||||||
|
?assert(maps:is_key(distance_km, Hit)).
|
||||||
|
|
||||||
|
test_geo_no_radius() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
CalendarId = create_test_calendar(OwnerId, commercial, []),
|
||||||
|
StartTime = eh_test_support:future_start(),
|
||||||
|
MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173},
|
||||||
|
SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351},
|
||||||
|
create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc),
|
||||||
|
create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc),
|
||||||
|
{Total, _} = events_from(logic_search:search(<<"event">>, undefined, OwnerId,
|
||||||
|
#{lat => 55.7558, lon => 37.6173})),
|
||||||
|
?assertEqual(2, Total).
|
||||||
|
|
||||||
|
test_geo_sort_distance() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
CalendarId = create_test_calendar(OwnerId, commercial, []),
|
||||||
|
StartTime = eh_test_support:future_start(),
|
||||||
|
MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173},
|
||||||
|
SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351},
|
||||||
|
create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc),
|
||||||
|
create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc),
|
||||||
|
{_, [First | _]} = events_from(logic_search:search(<<"event">>, undefined, OwnerId,
|
||||||
|
#{lat => 55.7558, lon => 37.6173})),
|
||||||
|
?assertMatch(#{title := <<"Moscow Event">>}, First),
|
||||||
|
?assert(maps:is_key(distance_km, First)).
|
||||||
|
|
||||||
|
set_default_location(CalendarId, Addr, Lat, Lon) ->
|
||||||
|
{ok, Cal} = core_calendar:get_by_id(CalendarId),
|
||||||
|
Settings0 = case Cal#calendar.settings of
|
||||||
|
M when is_map(M) -> M;
|
||||||
|
_ -> #{}
|
||||||
|
end,
|
||||||
|
Settings = Settings0#{
|
||||||
|
<<"default_location">> => #{
|
||||||
|
<<"address">> => Addr,
|
||||||
|
<<"lat">> => Lat,
|
||||||
|
<<"lon">> => Lon
|
||||||
|
}
|
||||||
|
},
|
||||||
|
core_calendar:update(CalendarId, [{settings, Settings}]).
|
||||||
|
|
||||||
test_combined_search() ->
|
test_combined_search() ->
|
||||||
OwnerId = create_test_user(user),
|
OwnerId = create_test_user(user),
|
||||||
CalendarId = create_test_calendar(OwnerId, commercial, []),
|
CalendarId = create_test_calendar(OwnerId, commercial, []),
|
||||||
|
|||||||
Reference in New Issue
Block a user