Пагинация, фильтры и сортировки Часть 1 #20

This commit is contained in:
2026-05-09 13:38:54 +03:00
parent ecf68ee300
commit a34e36b966
5 changed files with 316 additions and 40 deletions
@@ -113,12 +113,14 @@ convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
convert_field({<<"status">>, Val}) -> {status, Val};
convert_field({<<"status">>, Val}) ->
try binary_to_existing_atom(Val, utf8) of
Atom -> {status, Atom}
catch
error:badarg -> {status, Val} % fallback, но лучше залогировать
end;
convert_field(Other) -> Other.
%% event_to_json, datetime_to_iso8601, parse_datetime, parse_datetime_binary
%% берутся те же, что и в admin_handler_events.erl (можно вынести в общий модуль,
%% но для простоты дублируем).
event_to_json(Event) ->
LocationJson = case Event#event.location of
undefined -> null;
+41 -32
View File
@@ -6,37 +6,51 @@
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> ->
list_all_events(Req);
_ ->
send_error(Req, 405, <<"Method not allowed">>)
<<"GET">> -> list_all_events(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
%% GET /v1/admin/events
list_all_events(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
Filters = parse_filters(Req1),
{ok, Events} = logic_event:list_all_events(Filters),
Params = parse_admin_event_search(Req1),
{ok, Total, Events} = logic_event:search_events(Params),
Json = [event_to_json(E) || E <- Events],
send_json(Req1, 200, Json);
Limit = maps:get(limit, Params, 50),
Offset = maps:get(offset, Params, 0),
RangeEnd = min(Offset + Limit - 1, Total - 1),
Headers = #{
<<"content-type">> => <<"application/json">>,
<<"content-range">> => iolist_to_binary(
io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
},
Body = jsx:encode(Json),
cowboy_req:reply(200, Headers, Body, Req1),
{ok, Body, []};
{error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg)
end.
%% --- Вспомогательные функции ---
parse_filters(Req) ->
parse_admin_event_search(Req) ->
Qs = cowboy_req:parse_qs(Req),
lists:filtermap(
fun
({<<"from">>, Val}) -> {true, {from, parse_datetime_binary(Val)}};
({<<"to">>, Val}) -> {true, {to, parse_datetime_binary(Val)}};
(_) -> false
end,
Qs
).
#{
from => parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
to => parse_datetime_qs(proplists:get_value(<<"to">>, Qs)),
status => proplists:get_value(<<"status">>, Qs, undefined),
calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined),
title => proplists:get_value(<<"title">>, Qs, undefined),
q => proplists:get_value(<<"q">>, Qs, undefined),
limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
order => proplists:get_value(<<"order">>, Qs, <<"desc">>)
}.
%%--------------------------------------------------------------------
%% Вспомогательные функции
%%--------------------------------------------------------------------
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
@@ -48,7 +62,14 @@ auth_admin(Req) ->
{error, Code, Msg, Req1}
end.
%% Сериализация события (полностью скопирована из handler_event_by_id.erl)
parse_int_qs(undefined, Default) -> Default;
parse_int_qs(Bin, Default) ->
try binary_to_integer(Bin) catch _:_ -> Default end.
parse_datetime_qs(undefined) -> undefined;
parse_datetime_qs(Bin) ->
case parse_datetime(Bin) of {ok, Dt} -> Dt; _ -> undefined end.
event_to_json(Event) ->
LocationJson = case Event#event.location of
undefined -> null;
@@ -97,12 +118,6 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
datetime_to_iso8601(undefined) ->
undefined.
parse_datetime_binary(Str) ->
case parse_datetime(Str) of
{ok, Dt} -> Dt;
_ -> undefined
end.
parse_datetime(Str) ->
try
[DateStr, TimeStr] = string:split(Str, "T"),
@@ -119,12 +134,6 @@ parse_datetime(Str) ->
catch _:_ -> {error, invalid_format}
end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
Headers = #{<<"content-type">> => <<"application/json">>},
cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
+2
View File
@@ -227,6 +227,8 @@ table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(f
create_indices() ->
mnesia:add_table_index(event, calendar_id),
mnesia:add_table_index(event, title),
mnesia:add_table_index(event, created_at),
mnesia:add_table_index(event, start_time),
mnesia:add_table_index(event, event_type),
mnesia:add_table_index(event, master_id),
+110
View File
@@ -6,6 +6,9 @@
-export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]).
-export([materialize_for_booking/3]).
-export([list_all_events/1, get_event_admin/1, update_event_admin/2, delete_event_admin/1]).
-export([search_events/1]).
-define(DEFAULT_SEARCH_DAYS, 30).
%% Создание одиночного события
create_event(UserId, CalendarId, Title, StartTime, Duration) ->
@@ -168,6 +171,112 @@ delete_event(UserId, EventId) ->
Error
end.
%% @doc Поиск событий с пагинацией, фильтрацией и сортировкой
search_events(Params) ->
#{
from := FromIn, to := ToIn,
status := StatusFilter,
calendar_id := CalId,
title := TitleExact,
q := Query,
limit := Limit,
offset := Offset,
sort := SortField,
order := Order
} = Params,
{From, To} = ensure_time_range(FromIn, ToIn),
%% 1. Получаем исходный список событий
AllEvents = case CalId of
undefined ->
% Без календаря — только активные мастер-события
core_event:list_all();
_ ->
% Для конкретного календаря загружаем все события (любой статус)
mnesia:dirty_index_match_object(
event,
#event{calendar_id = CalId, _ = '_'},
calendar_id
)
end,
%% 2. Фильтрация по дате (игнорируем undefined и instance)
TimeFiltered = [E || E <- AllEvents,
E#event.is_instance =:= false,
E#event.start_time =/= undefined,
E#event.start_time >= From,
E#event.start_time =< To],
%% 3. Фильтр по точному названию
TitleFiltered = if TitleExact /= undefined ->
[E || E <- TimeFiltered, E#event.title =:= TitleExact];
true -> TimeFiltered
end,
%% 4. Пост‑фильтры по статусу и поисковой строке
%% Определяем эффективный фильтр статуса
EffectiveStatus = case {CalId, StatusFilter} of
{undefined, undefined} -> undefined; % без календаря → только active
{_, undefined} -> <<"all">>; % конкретный календарь → все статусы
_ -> StatusFilter
end,
FinalFiltered = apply_post_filters(TitleFiltered, EffectiveStatus, Query),
%% 5. Сортировка и пагинация
SortedEvents = sort_events(FinalFiltered, SortField, Order),
Total = length(SortedEvents),
Page = lists:sublist(SortedEvents, Offset + 1, Limit),
{ok, Total, Page}.
%% Дополнительная фильтрация по статусу и подстроке
apply_post_filters(Events, StatusFilter, Query) ->
E1 = case StatusFilter of
<<"all">> -> Events;
undefined -> [E || E <- Events, E#event.status =:= active];
_ ->
try binary_to_existing_atom(StatusFilter, utf8) of
Atom -> [E || E <- Events, E#event.status =:= Atom]
catch
error:badarg -> Events
end
end,
case Query of
undefined -> E1;
_ -> [E || E <- E1,
string:str(binary_to_list(E#event.title), binary_to_list(Query)) > 0 orelse
string:str(binary_to_list(E#event.description), binary_to_list(Query)) > 0]
end.
%%--------------------------------------------------------------------
%% Внутренние функции для search_events
%%--------------------------------------------------------------------
ensure_time_range(undefined, undefined) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
FromSec = NowSec - ?DEFAULT_SEARCH_DAYS * 86400,
ToSec = NowSec + ?DEFAULT_SEARCH_DAYS * 86400,
{calendar:gregorian_seconds_to_datetime(FromSec),
calendar:gregorian_seconds_to_datetime(ToSec)};
ensure_time_range(From, undefined) ->
{From, calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(From) + ?DEFAULT_SEARCH_DAYS * 2 * 86400)};
ensure_time_range(undefined, To) ->
{calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(To) - ?DEFAULT_SEARCH_DAYS * 2 * 86400), To};
ensure_time_range(From, To) -> {From, To}.
sort_events(Events, SortField, Order) ->
Field = binary_to_existing_atom(SortField, utf8),
Sorted = lists:sort(
fun(A, B) ->
ValA = event_field(A, Field),
ValB = event_field(B, Field),
if Order == <<"asc">> -> ValA =< ValB;
true -> ValA >= ValB
end
end, Events),
Sorted.
event_field(Event, created_at) -> Event#event.created_at;
event_field(Event, start_time) -> Event#event.start_time;
event_field(Event, title) -> Event#event.title;
event_field(Event, status) -> Event#event.status;
event_field(_, _) -> undefined.
%% Валидация времени события (без учёта пользователя)
validate_event_time(StartTime) ->
validate_event_time(StartTime, undefined).
@@ -214,6 +323,7 @@ validate_update({location, Value}, _) ->
validate_update({tags, Value}, _) when is_list(Value) -> true;
validate_update({capacity, Value}, _) when is_integer(Value), Value > 0 -> true;
validate_update({online_link, Value}, _) when is_binary(Value) -> true;
validate_update({status, Value}, _) when is_atom(Value) -> true;
validate_update(_, _) -> false.
get_exceptions(MasterId) ->