Рефакторинг админских обработчиков - пагинация, сортировка, фильтрация. Финал
This commit is contained in:
@@ -130,6 +130,7 @@ create_admin(Req) ->
|
||||
_ ->
|
||||
case logic_admin:create_admin(Email, Password, Role) of
|
||||
{ok, Admin} ->
|
||||
% Аудит создания администратора
|
||||
admin_utils:log_admin_action(SuperAdminId, <<"create_admin">>, <<"admin">>, Email, Req2),
|
||||
handler_utils:send_json(Req2, 201, handler_utils:admin_to_json(Admin));
|
||||
{error, email_exists} ->
|
||||
@@ -158,7 +159,14 @@ create_admin(Req) ->
|
||||
parse_admin_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
role => proplists:get_value(<<"role">>, Qs),
|
||||
status => proplists:get_value(<<"status">>, Qs),
|
||||
role => to_atom(proplists:get_value(<<"role">>, Qs)),
|
||||
status => to_atom(proplists:get_value(<<"status">>, Qs)),
|
||||
q => proplists:get_value(<<"q">>, Qs)
|
||||
}.
|
||||
}.
|
||||
|
||||
%% @private Безопасное преобразование бинарного значения в атом.
|
||||
to_atom(undefined) -> undefined;
|
||||
to_atom(Bin) when is_binary(Bin) ->
|
||||
try binary_to_existing_atom(Bin, utf8)
|
||||
catch error:badarg -> Bin
|
||||
end.
|
||||
@@ -1,6 +1,6 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик журнала аудита.
|
||||
%%% GET – список записей аудита с пагинацией и фильтрацией.
|
||||
%%% GET – список записей аудита с пагинацией, фильтрацией и сортировкой.
|
||||
%%% Доступно только суперадмину.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -33,6 +33,8 @@ trails() ->
|
||||
#{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>},
|
||||
#{name => <<"date_from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>},
|
||||
#{name => <<"date_to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>},
|
||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"timestamp">>, <<"action">>, <<"admin_id">>, <<"entity_type">>]}, description => <<"Sort field">>},
|
||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
@@ -77,16 +79,20 @@ audit_schema() ->
|
||||
-spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_audit(Req) ->
|
||||
case handler_utils:is_superadmin(Req) of
|
||||
{ok, SuperAdminId, Req1} ->
|
||||
{ok, _SuperAdminId, Req1} ->
|
||||
Filters = parse_audit_filters(Req1),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
Pagination0 = handler_utils:parse_pagination_params(Req1),
|
||||
Pagination = Pagination0#{
|
||||
sort => maps:get(sort, Pagination0, <<"timestamp">>),
|
||||
order => maps:get(order, Pagination0, <<"desc">>)
|
||||
},
|
||||
AllRecords = core_admin_audit:list(Filters),
|
||||
Total = length(AllRecords),
|
||||
Page = lists:sublist(AllRecords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
|
||||
Sorted = sort_audit_records(AllRecords, Pagination),
|
||||
Total = length(Sorted),
|
||||
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
|
||||
Json = [handler_utils:audit_to_json(R) || R <- Page],
|
||||
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
|
||||
% Аудит доступа к журналу
|
||||
admin_utils:log_admin_action(SuperAdminId, <<"view_audit">>, <<"audit">>, <<"list">>, Req1),
|
||||
% Аудит чтения списка аудита НЕ пишем, чтобы не замусоривать журнал
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
@@ -105,4 +111,22 @@ parse_audit_filters(Req) ->
|
||||
{action, proplists:get_value(<<"action">>, Qs)},
|
||||
{date_from, handler_utils:parse_datetime_qs(proplists:get_value(<<"date_from">>, Qs))},
|
||||
{date_to, handler_utils:parse_datetime_qs(proplists:get_value(<<"date_to">>, Qs))}
|
||||
].
|
||||
].
|
||||
|
||||
%% @private Сортирует записи аудита по указанному полю.
|
||||
sort_audit_records(Records, #{sort := Sort, order := Order}) ->
|
||||
Field = binary_to_existing_atom(Sort, utf8),
|
||||
lists:sort(fun(A, B) ->
|
||||
VA = audit_field(A, Field),
|
||||
VB = audit_field(B, Field),
|
||||
case Order of
|
||||
<<"desc">> -> VA > VB;
|
||||
_ -> VA < VB
|
||||
end
|
||||
end, Records).
|
||||
|
||||
audit_field(#admin_audit{timestamp = V}, timestamp) -> V;
|
||||
audit_field(#admin_audit{action = V}, action) -> V;
|
||||
audit_field(#admin_audit{admin_id = V}, admin_id) -> V;
|
||||
audit_field(#admin_audit{entity_type = V}, entity_type) -> V;
|
||||
audit_field(_, _) -> undefined.
|
||||
@@ -1,6 +1,6 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик бан-слов.
|
||||
%%% GET – список всех слов с пагинацией.
|
||||
%%% GET – список всех слов с пагинацией, фильтрацией и сортировкой.
|
||||
%%% POST – добавить новое слово.
|
||||
%%% PUT – редактировать существующее слово.
|
||||
%%% DELETE – удалить слово по :word.
|
||||
@@ -37,6 +37,9 @@ trails() ->
|
||||
description => <<"List all banned words (admin)">>,
|
||||
tags => [<<"Banned Words">>],
|
||||
parameters => [
|
||||
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by word">>},
|
||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"word">>, <<"added_at">>]}, description => <<"Sort field">>},
|
||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
@@ -141,11 +144,27 @@ banned_word_schema() ->
|
||||
list_words(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
Q = proplists:get_value(<<"q">>, Qs),
|
||||
SortBy = proplists:get_value(<<"sort">>, Qs, <<"word">>),
|
||||
SortOrder = proplists:get_value(<<"order">>, Qs, <<"asc">>),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
AllWords = core_banned_words:list_banned_words(),
|
||||
BannedWords = lists:sort(AllWords),
|
||||
Total = length(BannedWords),
|
||||
Page = lists:sublist(BannedWords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
|
||||
% Фильтрация
|
||||
Filtered = case Q of
|
||||
undefined -> AllWords;
|
||||
_ -> [W || W <- AllWords, string:str(string:lowercase(binary_to_list(W#banned_word.word)), string:lowercase(binary_to_list(Q))) > 0]
|
||||
end,
|
||||
% Сортировка
|
||||
Sorted = case {SortBy, SortOrder} of
|
||||
{<<"word">>, <<"asc">>} -> lists:keysort(#banned_word.word, Filtered);
|
||||
{<<"word">>, <<"desc">>} -> lists:reverse(lists:keysort(#banned_word.word, Filtered));
|
||||
{<<"added_at">>, <<"asc">>} -> lists:keysort(#banned_word.added_at, Filtered);
|
||||
{<<"added_at">>, <<"desc">>} -> lists:reverse(lists:keysort(#banned_word.added_at, Filtered));
|
||||
_ -> Filtered
|
||||
end,
|
||||
Total = length(Sorted),
|
||||
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
|
||||
Json = [word_to_map(W) || W <- Page],
|
||||
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
|
||||
@@ -32,6 +32,12 @@ trails() ->
|
||||
description => <<"Filter by target type">>},
|
||||
#{name => <<"q">>, in => <<"query">>, schema => #{type => string},
|
||||
description => <<"Search in reason">>},
|
||||
#{name => <<"sort">>, in => <<"query">>,
|
||||
schema => #{type => string, enum => [<<"created_at">>, <<"status">>]},
|
||||
description => <<"Sort field">>},
|
||||
#{name => <<"order">>, in => <<"query">>,
|
||||
schema => #{type => string, enum => [<<"asc">>, <<"desc">>]},
|
||||
description => <<"Sort direction">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer},
|
||||
description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer},
|
||||
@@ -69,7 +75,11 @@ list_reports(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
Filters = parse_report_filters(Req1),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
Pagination0 = handler_utils:parse_pagination_params(Req1),
|
||||
Pagination = Pagination0#{
|
||||
sort => maps:get(sort, Pagination0, <<"created_at">>),
|
||||
order => maps:get(order, Pagination0, <<"desc">>)
|
||||
},
|
||||
case logic_report:list_reports(AdminId) of
|
||||
{ok, AllReports} ->
|
||||
Filtered = apply_report_filters(AllReports, Filters),
|
||||
@@ -89,9 +99,19 @@ list_reports(Req) ->
|
||||
|
||||
parse_report_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{ status => proplists:get_value(<<"status">>, Qs),
|
||||
target_type => proplists:get_value(<<"target_type">>, Qs),
|
||||
q => proplists:get_value(<<"q">>, Qs) }.
|
||||
StatusBin = proplists:get_value(<<"status">>, Qs),
|
||||
TargetBin = proplists:get_value(<<"target_type">>, Qs),
|
||||
#{
|
||||
status => convert_to_atom(StatusBin),
|
||||
target_type => convert_to_atom(TargetBin),
|
||||
q => proplists:get_value(<<"q">>, Qs)
|
||||
}.
|
||||
|
||||
convert_to_atom(undefined) -> undefined;
|
||||
convert_to_atom(Bin) when is_binary(Bin) ->
|
||||
try binary_to_existing_atom(Bin, utf8)
|
||||
catch error:badarg -> Bin
|
||||
end.
|
||||
|
||||
apply_report_filters(Reports, Filters) ->
|
||||
Status = maps:get(status, Filters, undefined),
|
||||
|
||||
@@ -31,10 +31,10 @@ trails() ->
|
||||
#{name => <<"target_id">>, in => <<"query">>, schema => #{type => string}, description => <<"ID of target">>},
|
||||
#{name => <<"user_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by user">>},
|
||||
#{name => <<"status">>, in => <<"query">>, schema => #{type => string}, description => <<"visible, hidden, deleted, or all">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>},
|
||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"created_at">>, <<"updated_at">>, <<"rating">>]}, description => <<"Sort field">>},
|
||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>}
|
||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{ description => <<"Array of reviews">>, content => #{<<"application/json">> => #{schema => #{ type => array, items => review_schema() }}} }
|
||||
@@ -83,16 +83,11 @@ list_reviews(Req) ->
|
||||
target_type => normalize_target_type(proplists:get_value(<<"target_type">>, Qs)),
|
||||
target_id => proplists:get_value(<<"target_id">>, Qs),
|
||||
user_id => proplists:get_value(<<"user_id">>, Qs),
|
||||
status => proplists:get_value(<<"status">>, Qs)
|
||||
status => normalize_status(proplists:get_value(<<"status">>, Qs))
|
||||
},
|
||||
Filters = maps:filter(fun(_, V) -> V =/= undefined end, Filters0),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
% Убедимся, что в Pagination есть ключи sort и order со значениями по умолчанию
|
||||
PaginationWithSort = Pagination#{
|
||||
sort => maps:get(sort, Pagination, <<"created_at">>),
|
||||
order => maps:get(order, Pagination, <<"desc">>)
|
||||
},
|
||||
{ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, PaginationWithSort),
|
||||
{ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, Pagination),
|
||||
Json = [handler_utils:review_to_json(R) || R <- Reviews],
|
||||
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
@@ -105,6 +100,12 @@ normalize_target_type(<<"event">>) -> event;
|
||||
normalize_target_type(<<"calendar">>) -> calendar;
|
||||
normalize_target_type(Other) -> Other.
|
||||
|
||||
%% @private Преобразует бинарный status в атом.
|
||||
normalize_status(<<"visible">>) -> visible;
|
||||
normalize_status(<<"hidden">>) -> hidden;
|
||||
normalize_status(<<"deleted">>) -> deleted;
|
||||
normalize_status(Other) -> Other.
|
||||
|
||||
bulk_update_reviews(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
|
||||
@@ -24,6 +24,8 @@ trails() ->
|
||||
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>},
|
||||
#{name => <<"assigned_to">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by assigned admin ID">>},
|
||||
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in error_message">>},
|
||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"first_seen">>, <<"last_seen">>, <<"status">>]}, description => <<"Sort field">>},
|
||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
@@ -51,8 +53,7 @@ trails() ->
|
||||
].
|
||||
|
||||
ticket_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
#{ type => object,
|
||||
properties => #{
|
||||
id => #{type => string},
|
||||
reporter_id => #{type => string},
|
||||
@@ -70,8 +71,7 @@ ticket_schema() ->
|
||||
}.
|
||||
|
||||
ticket_update_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
#{ type => object,
|
||||
properties => #{
|
||||
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
|
||||
assigned_to => #{type => string},
|
||||
@@ -85,7 +85,11 @@ list_tickets(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
Filters = parse_ticket_filters(Req1),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
Pagination0 = handler_utils:parse_pagination_params(Req1),
|
||||
Pagination = Pagination0#{
|
||||
sort => maps:get(sort, Pagination0, <<"first_seen">>),
|
||||
order => maps:get(order, Pagination0, <<"desc">>)
|
||||
},
|
||||
TicketsResult = case maps:get(status, Filters, undefined) of
|
||||
undefined -> logic_ticket:list_tickets(AdminId);
|
||||
StatusBin ->
|
||||
|
||||
@@ -123,14 +123,11 @@ parse_int_qs(Bin, Default) ->
|
||||
|
||||
-spec pagination_headers(map(), non_neg_integer()) -> map().
|
||||
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
|
||||
NextOffset = Offset + Limit,
|
||||
HasMore = NextOffset < Total,
|
||||
RangeEnd = min(Offset + Limit - 1, Total - 1),
|
||||
#{
|
||||
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
|
||||
<<"x-total-count">> => integer_to_binary(Total),
|
||||
<<"x-next-offset">> => case HasMore of
|
||||
true -> integer_to_binary(NextOffset);
|
||||
false -> <<"">>
|
||||
end
|
||||
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
|
||||
}.
|
||||
|
||||
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
|
||||
|
||||
@@ -208,6 +208,11 @@ list_admin_reviews(Filters) ->
|
||||
%% Вспомогательная функция: фильтрация списка по proplist
|
||||
apply_filters(Reviews, []) ->
|
||||
Reviews;
|
||||
apply_filters(Reviews, [{status, Status} | Rest]) ->
|
||||
apply_filters(
|
||||
[R || R <- Reviews, R#review.status =:= Status],
|
||||
Rest
|
||||
);
|
||||
apply_filters(Reviews, [{target_type, Type} | Rest]) ->
|
||||
apply_filters(
|
||||
[R || R <- Reviews, R#review.target_type =:= Type],
|
||||
@@ -224,7 +229,7 @@ apply_filters(Reviews, [{user_id, UserId} | Rest]) ->
|
||||
Rest
|
||||
);
|
||||
apply_filters(Reviews, [_ | Rest]) ->
|
||||
apply_filters(Reviews, Rest). % Игнорируем неизвестные фильтры
|
||||
apply_filters(Reviews, Rest).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Массово обновить статусы отзывов.
|
||||
|
||||
Reference in New Issue
Block a user