Расширена общая статистика по дням: Отзывы, Календари, Жалобы, Тикеты, Подписки #22

This commit is contained in:
2026-05-27 22:40:03 +03:00
parent 4444461ee3
commit 2dac5e5102
9 changed files with 250 additions and 39 deletions
+22
View File
@@ -3,6 +3,7 @@
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). -export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]).
-export([count_calendars/0, list_all/0]). -export([count_calendars/0, list_all/0]).
-export([freeze/2, unfreeze/2]). -export([freeze/2, unfreeze/2]).
-export([count_calendars_by_date/2]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -152,6 +153,27 @@ freeze(Id, Reason) ->
unfreeze(Id, Reason) -> unfreeze(Id, Reason) ->
update(Id, [{status, active}, {reason, Reason}]). update(Id, [{status, active}, {reason, Reason}]).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт календарей, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-spec count_calendars_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_calendars_by_date(From, To) ->
All = mnesia:dirty_match_object(#calendar{_ = '_'}),
Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso
C#calendar.created_at =< To end, All),
Counts = lists:foldl(fun(C, Acc) ->
Day = date_part(C#calendar.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%=================================================================== %%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ %%% ВНУТРЕННИЕ ФУНКЦИИ
%%%=================================================================== %%%===================================================================
+22
View File
@@ -3,6 +3,7 @@
-export([create/4, get_by_id/1, list_all/0, list_by_reporter/1, update_status/3, count_reports/0]). -export([create/4, get_by_id/1, list_all/0, list_by_reporter/1, update_status/3, count_reports/0]).
-export([list_by_target/2, get_count_by_target/2]). -export([list_by_target/2, get_count_by_target/2]).
-export([count_reports_by_status/1, count_reports_resolved_by_admin/2, avg_resolution_time/1]). -export([count_reports_by_status/1, count_reports_resolved_by_admin/2, avg_resolution_time/1]).
-export([count_reports_by_date/2]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -157,3 +158,24 @@ avg_resolution_time(Status) ->
|| R <- Reports, R#report.resolved_at /= undefined]), || R <- Reports, R#report.resolved_at /= undefined]),
Total / length(Reports) / 3600.0 Total / length(Reports) / 3600.0
end. end.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-spec count_reports_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_reports_by_date(From, To) ->
All = mnesia:dirty_match_object(#report{_ = '_'}),
Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso
R#report.created_at =< To end, All),
Counts = lists:foldl(fun(R, Acc) ->
Day = date_part(R#report.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
+22
View File
@@ -4,6 +4,7 @@
hide/2, unhide/2]). hide/2, unhide/2]).
-export([get_average_rating/2, has_user_reviewed/3]). -export([get_average_rating/2, has_user_reviewed/3]).
-export([count_reviews/0, list_all/0]). -export([count_reviews/0, list_all/0]).
-export([count_reviews_by_date/2]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -182,6 +183,27 @@ count_reviews() ->
list_all() -> list_all() ->
mnesia:dirty_match_object(#review{_ = '_'}). mnesia:dirty_match_object(#review{_ = '_'}).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт отзывов, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-spec count_reviews_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_reviews_by_date(From, To) ->
All = mnesia:dirty_match_object(#review{_ = '_'}),
Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso
R#review.created_at =< To end, All),
Counts = lists:foldl(fun(R, Acc) ->
Day = date_part(R#review.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%=================================================================== %%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ %%% ВНУТРЕННИЕ ФУНКЦИИ
%%%=================================================================== %%%===================================================================
+22
View File
@@ -4,6 +4,7 @@
-export([update_status/2, check_expired/0]). -export([update_status/2, check_expired/0]).
-export([list_subscriptions/0, create_subscription/1, update_subscription/2, delete_subscription/1]). -export([list_subscriptions/0, create_subscription/1, update_subscription/2, delete_subscription/1]).
-export([count_subscription/0]). -export([count_subscription/0]).
-export([count_subscriptions_by_date/2]).
-define(TRIAL_DAYS, 30). -define(TRIAL_DAYS, 30).
@@ -293,3 +294,24 @@ parse_iso_datetime(Other) -> Other.
-spec count_subscription() -> non_neg_integer(). -spec count_subscription() -> non_neg_integer().
count_subscription() -> count_subscription() ->
mnesia:table_info(subscription, size). mnesia:table_info(subscription, size).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscriptions_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_subscriptions_by_date(From, To) ->
All = mnesia:dirty_match_object(#subscription{_ = '_'}),
Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso
S#subscription.created_at =< To end, All),
Counts = lists:foldl(fun(S, Acc) ->
Day = date_part(S#subscription.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
+23 -1
View File
@@ -7,7 +7,8 @@
-export([list_by_user/1]). -export([list_by_user/1]).
-export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2]). -export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2]).
-export([update_ticket/2]). -export([update_ticket/2]).
-export([delete_ticket/1]). % ← для admin_handler_ticket_by_id -export([delete_ticket/1]).
-export([count_tickets_by_date/2]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -248,6 +249,27 @@ count_tickets_by_admin(AdminId, Status) ->
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'}, Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)). length(mnesia:dirty_match_object(Match)).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт тикетов, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-spec count_tickets_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_tickets_by_date(From, To) ->
All = mnesia:dirty_match_object(#ticket{_ = '_'}),
Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso
T#ticket.first_seen =< To end, All),
Counts = lists:foldl(fun(T, Acc) ->
Day = date_part(T#ticket.first_seen),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%=================================================================== %%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ %%% ВНУТРЕННИЕ ФУНКЦИИ
%%%=================================================================== %%%===================================================================
+30 -1
View File
@@ -5,7 +5,7 @@
-export([email_exists/1]). -export([email_exists/1]).
-export([list_users/0]). -export([list_users/0]).
-export([block/2, unblock/2]). -export([block/2, unblock/2]).
-export([count_users/0, count_users_by_date/2, list_all/0]). -export([count_users/0, count_users_by_date/2, count_pending_users/0, count_pending_users_by_date/2, list_all/0]).
-export([create_bot/2, delete_bot/1]). -export([create_bot/2, delete_bot/1]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
@@ -277,6 +277,35 @@ count_users_by_date(From, To) ->
end, [], Filtered), end, [], Filtered),
lists:sort(Counts). lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Количество неподтверждённых пользователей (status = pending).
%%% @end
%%%-------------------------------------------------------------------
-spec count_pending_users() -> non_neg_integer().
count_pending_users() ->
Match = #user{status = pending, _ = '_'},
length(mnesia:dirty_match_object(Match)).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням.
%%% @end
%%%-------------------------------------------------------------------
-spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_pending_users_by_date(From, To) ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, Pending),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @doc Выделить из datetime только дату `{Y,M,D}`.
%%% @end %%% @end
+60 -12
View File
@@ -35,10 +35,7 @@ trails() ->
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Statistics object">>, description => <<"Statistics object">>,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => stats_schema()}}
type => object,
properties => stats_schema()
}}}
}, },
403 => #{description => <<"Admin access required">>} 403 => #{description => <<"Admin access required">>}
} }
@@ -47,14 +44,65 @@ trails() ->
stats_schema() -> stats_schema() ->
#{ #{
<<"users">> => #{type => integer, description => <<"Total number of users">>}, type => object,
<<"events">> => #{type => integer}, properties => #{
<<"reviews">> => #{type => integer}, <<"users_total">> => #{type => integer, description => <<"Total number of users">>},
<<"calendars">> => #{type => integer}, <<"events_total">> => #{type => integer, description => <<"Total number of events">>},
<<"reports">> => #{type => integer}, <<"reviews_total">> => #{type => integer, description => <<"Total number of reviews">>},
<<"tickets">> => #{type => integer}, <<"calendars_total">> => #{type => integer, description => <<"Total number of calendars">>},
<<"subscriptions">> => #{type => integer}, <<"reports_total">> => #{type => integer, description => <<"Total number of reports">>},
<<"active_subscriptions">> => #{type => integer} <<"tickets_total">> => #{type => integer, description => <<"Total number of tickets">>},
<<"tickets_open">> => #{type => integer, description => <<"Number of open tickets">>},
<<"subscriptions_total">> => #{type => integer, description => <<"Total number of subscriptions">>},
<<"active_subscriptions">> => #{type => integer, description => <<"Number of active subscriptions">>},
<<"pending_users_total">> => #{type => integer, description => <<"Number of users awaiting email verification">>},
<<"avg_ticket_resolution_h">> => #{type => number, format => float, description => <<"Average ticket resolution time in hours">>},
<<"registrations_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily user registrations">>
},
<<"events_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily event creations">>
},
<<"reviews_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily review creations">>
},
<<"calendars_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily calendar creations">>
},
<<"reports_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily report creations">>
},
<<"tickets_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily ticket creations">>
},
<<"subscriptions_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily subscription creations">>
},
<<"pending_users_by_day">> => #{
type => array,
items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}},
description => <<"Daily pending user registrations">>
},
<<"admin_activity">> => #{
type => array,
items => #{type => object},
description => <<"Recent admin actions (superadmin only)">>
}
}
}. }.
%%% Internal functions %%% Internal functions
+15 -1
View File
@@ -49,6 +49,7 @@ get_stats(_, _, _, _) ->
build_superadmin_stats(From, To) -> build_superadmin_stats(From, To) ->
#{ #{
users_total => core_user:count_users(), users_total => core_user:count_users(),
pending_users_total => core_user:count_pending_users(),
events_total => core_event:count_events(), events_total => core_event:count_events(),
calendars_total => core_calendar:count_calendars(), calendars_total => core_calendar:count_calendars(),
reviews_total => core_review:count_reviews(), reviews_total => core_review:count_reviews(),
@@ -58,6 +59,12 @@ build_superadmin_stats(From, To) ->
avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()),
registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)), registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)),
events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)), events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)),
reviews_by_day => date_list_to_json(core_review:count_reviews_by_date(From, To)),
calendars_by_day => date_list_to_json(core_calendar:count_calendars_by_date(From, To)),
reports_by_day => date_list_to_json(core_report:count_reports_by_date(From, To)),
tickets_by_day => date_list_to_json(core_ticket:count_tickets_by_date(From, To)),
subscriptions_by_day => date_list_to_json(core_subscription:count_subscriptions_by_date(From, To)),
pending_users_by_day => date_list_to_json(core_user:count_pending_users_by_date(From, To)),
admin_activity => collect_admin_activity() admin_activity => collect_admin_activity()
}. }.
@@ -69,6 +76,7 @@ build_superadmin_stats(From, To) ->
build_admin_stats(From, To) -> build_admin_stats(From, To) ->
#{ #{
users_total => core_user:count_users(), users_total => core_user:count_users(),
pending_users_total => core_user:count_pending_users(),
events_total => core_event:count_events(), events_total => core_event:count_events(),
calendars_total => core_calendar:count_calendars(), calendars_total => core_calendar:count_calendars(),
reviews_total => core_review:count_reviews(), reviews_total => core_review:count_reviews(),
@@ -77,7 +85,13 @@ build_admin_stats(From, To) ->
tickets_total => length(core_ticket:list_all()), tickets_total => length(core_ticket:list_all()),
avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()),
registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)), registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)),
events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)) events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)),
reviews_by_day => date_list_to_json(core_review:count_reviews_by_date(From, To)),
calendars_by_day => date_list_to_json(core_calendar:count_calendars_by_date(From, To)),
reports_by_day => date_list_to_json(core_report:count_reports_by_date(From, To)),
tickets_by_day => date_list_to_json(core_ticket:count_tickets_by_date(From, To)),
subscriptions_by_day => date_list_to_json(core_subscription:count_subscriptions_by_date(From, To)),
pending_users_by_day => date_list_to_json(core_user:count_pending_users_by_date(From, To))
}. }.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
+32 -22
View File
@@ -2,28 +2,26 @@
%%% @doc Тесты административного API для получения статистики. %%% @doc Тесты административного API для получения статистики.
%%% %%%
%%% Покрывает эндпоинты: %%% Покрывает эндпоинты:
%%% GET /v1/admin/stats %%% GET /v1/admin/stats
%%% %%%
%%% Проверяет: %%% Проверяет:
%%% - получение статистики для всех четырёх ролей администраторов %%% - получение статистики для всех четырёх ролей администраторов
%%% - для superadmin и admin наличие ключевых метрик %%% - для superadmin и admin наличие ключевых метрик, включая
%%% - для moderator и support ответ непустой %%% pending_users_total, *_by_day
%%% - работу с фильтром по датам (from, to) %%% - для moderator и support ответ непустой
%%% - работу с фильтром по датам (from, to)
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_stats_tests). -module(admin_stats_tests).
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-export([test/0]). -export([test/0]).
%%%=================================================================== %%%===================================================================
%%% Главная тестовая функция %%% Главная тестовая функция
%%%=================================================================== %%%===================================================================
-spec test() -> ok. -spec test() -> ok.
test() -> test() ->
ct:pal("=== Admin Stats Tests ==="), ct:pal("=== Admin Stats Tests ==="),
SuperToken = api_test_runner:get_superadmin_token(), SuperToken = api_test_runner:get_superadmin_token(),
AdminToken = api_test_runner:get_admin_token(), AdminToken = api_test_runner:get_admin_token(),
ModerToken = api_test_runner:get_moderator_token(), ModerToken = api_test_runner:get_moderator_token(),
@@ -44,34 +42,46 @@ test() ->
%%%=================================================================== %%%===================================================================
%% @doc Проверяет получение статистики для конкретной роли. %% @doc Проверяет получение статистики для конкретной роли.
%% strict ожидаем ключи users_total/users и events_total/events %% strict ожидаем ключи users_total/events_total/pending_users_total и *_by_day
%% loose просто убеждаемся, что ответ непустой %% loose просто убеждаемся, что ответ непустой
-spec test_stats_for_role(string(), binary(), strict | loose) -> ok. -spec test_stats_for_role(string(), binary(), strict | loose) -> ok.
test_stats_for_role(RoleName, Token, Strictness) -> test_stats_for_role(RoleName, Token, Strictness) ->
ct:pal(" TEST: Get stats for role ~s", [RoleName]), ct:pal(" TEST: Get stats for role ~s", [RoleName]),
Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token), Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token),
?assert(is_map(Stats)), ?assert(is_map(Stats)),
case Strictness of case Strictness of
strict -> strict ->
HasUsers = maps:is_key(<<"users_total">>, Stats) orelse HasUsers = maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats),
maps:is_key(<<"users">>, Stats), HasEvents = maps:is_key(<<"events_total">>, Stats) orelse maps:is_key(<<"events">>, Stats),
HasEvents = maps:is_key(<<"events_total">>, Stats) orelse ?assert(HasUsers orelse HasEvents),
maps:is_key(<<"events">>, Stats), % Проверяем наличие новых ключей
?assert(HasUsers orelse HasEvents); ?assert(maps:is_key(<<"pending_users_total">>, Stats)),
?assert(maps:is_key(<<"reviews_by_day">>, Stats)),
?assert(maps:is_key(<<"calendars_by_day">>, Stats)),
?assert(maps:is_key(<<"reports_by_day">>, Stats)),
?assert(maps:is_key(<<"tickets_by_day">>, Stats)),
?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)),
?assert(maps:is_key(<<"pending_users_by_day">>, Stats)),
% Проверяем, что *_by_day являются списками
?assert(is_list(maps:get(<<"reviews_by_day">>, Stats))),
?assert(is_list(maps:get(<<"calendars_by_day">>, Stats))),
?assert(is_list(maps:get(<<"reports_by_day">>, Stats))),
?assert(is_list(maps:get(<<"tickets_by_day">>, Stats))),
?assert(is_list(maps:get(<<"subscriptions_by_day">>, Stats))),
?assert(is_list(maps:get(<<"pending_users_by_day">>, Stats)));
loose -> loose ->
?assert(map_size(Stats) > 0) ?assert(map_size(Stats) > 0)
end, end,
ct:pal(" OK: ~p keys", [length(maps:keys(Stats))]). ct:pal(" OK: ~p keys", [length(maps:keys(Stats))]).
%% @doc GET /v1/admin/stats?from=...&to=... проверяет фильтрацию по датам. %% @doc GET /v1/admin/stats?from=...&to=... проверяет фильтрацию по датам.
-spec test_stats_with_dates(binary()) -> ok. -spec test_stats_with_dates(binary()) -> ok.
test_stats_with_dates(Token) -> test_stats_with_dates(Token) ->
ct:pal(" TEST: Get stats with date range"), ct:pal(" TEST: Get stats with date range"),
From = <<"2026-01-01T00:00:00Z">>, From = <<"2026-01-01T00:00:00Z">>,
To = <<"2026-12-31T23:59:59Z">>, To = <<"2026-12-31T23:59:59Z">>,
Path = <<"/v1/admin/stats?from=", From/binary, "&to=", To/binary>>, Path = <<"/v1/admin/stats?from=", From/binary, "&to=", To/binary>>,
Stats = api_test_runner:admin_get(Path, Token), Stats = api_test_runner:admin_get(Path, Token),
?assert(is_map(Stats)), ?assert(is_map(Stats)),
?assert(maps:is_key(<<"users_total">>, Stats) orelse ?assert(maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats)),
maps:is_key(<<"users">>, Stats)), ct:pal(" OK").
ct:pal(" OK").