Рефакторинг админских обработчиков - пагинация, сортировка, фильтрация. Финал

This commit is contained in:
2026-05-26 21:50:12 +03:00
parent ed5b275efb
commit 19b3d0dba0
21 changed files with 1833 additions and 421 deletions
+11 -3
View File
@@ -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.
+32 -8
View File
@@ -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);
+24 -4
View File
@@ -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),
+11 -10
View File
@@ -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} ->
+9 -5
View File
@@ -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 ->
+3 -6
View File
@@ -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().
+6 -1
View File
@@ -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 Массово обновить статусы отзывов.