Расширена статистика для разделов #22

This commit is contained in:
2026-05-28 13:49:08 +03:00
parent 2dac5e5102
commit 6d90118120
19 changed files with 1189 additions and 68 deletions
+89
View File
@@ -4,6 +4,9 @@
-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]). -export([count_calendars_by_date/2]).
-export([count_calendars_by_type/0, count_calendars_by_status/0,
get_top_calendars_by_reviews/1, get_top_calendars_by_positive_reviews/1,
get_top_calendars_by_negative_reviews/1, get_top_calendars_by_rating/1]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -174,6 +177,92 @@ count_calendars_by_date(From, To) ->
date_part({{Y,M,D}, _}) -> {Y,M,D}. date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт календарей по типам (personal, commercial).
%%% @end
%%%-------------------------------------------------------------------
-spec count_calendars_by_type() -> [{atom(), non_neg_integer()}].
count_calendars_by_type() ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
lists:foldl(fun(#calendar{type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Calendars).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт календарей по статусам (active, frozen, deleted).
%%% @end
%%%-------------------------------------------------------------------
-spec count_calendars_by_status() -> [{atom(), non_neg_integer()}].
count_calendars_by_status() ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
lists:foldl(fun(#calendar{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Calendars).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству отзывов.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_reviews(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_reviews(N) ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#calendar.rating_count =< B#calendar.rating_count end,
Calendars)),
lists:sublist(Sorted, N).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству позитивных отзывов (rating >= 4).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_positive_reviews(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_positive_reviews(N) ->
top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству негативных отзывов (rating <= 2).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_negative_reviews(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_negative_reviews(N) ->
top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по среднему рейтингу.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_rating(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_rating(N) ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#calendar.rating_avg =< B#calendar.rating_avg end,
Calendars)),
lists:sublist(Sorted, N).
%% @private Вспомогательная функция для фильтрации отзывов и подсчёта по календарям.
top_by_review_filter(N, FilterFun) ->
Reviews = mnesia:dirty_match_object(#review{target_type = calendar, _ = '_'}),
Filtered = lists:filter(FilterFun, Reviews),
% Считаем количество отзывов для каждого календаря
Counts = lists:foldl(fun(#review{target_id = CalId}, Acc) ->
case lists:keyfind(CalId, 1, Acc) of
false -> [{CalId, 1} | Acc];
{CalId, C} -> lists:keyreplace(CalId, 1, Acc, {CalId, C+1})
end
end, [], Filtered),
% Сортируем по убыванию количества
SortedIds = [Id || {Id, _} <- lists:reverse(lists:sort(fun({_,C1},{_,C2}) -> C1 =< C2 end, Counts))],
TopIds = lists:sublist(SortedIds, N),
% Получаем объекты календарей
[Cal || Id <- TopIds,
{ok, Cal} <- [core_calendar:get_by_id(Id)]].
%%%=================================================================== %%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ %%% ВНУТРЕННИЕ ФУНКЦИИ
%%%=================================================================== %%%===================================================================
+41
View File
@@ -5,6 +5,7 @@
-export([count_events/0, count_events_by_date/2]). -export([count_events/0, count_events_by_date/2]).
-export([freeze/2, unfreeze/2]). -export([freeze/2, unfreeze/2]).
-export([list_all/0]). -export([list_all/0]).
-export([count_events_by_type/0, count_events_by_status/0, get_top_events_by_rating/1]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -251,6 +252,46 @@ count_events_by_date(From, To) ->
end, [], Filtered), end, [], Filtered),
lists:sort(Counts). lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий по типам (single, recurring).
%%% @end
%%%-------------------------------------------------------------------
-spec count_events_by_type() -> [{atom(), non_neg_integer()}].
count_events_by_type() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
lists:foldl(fun(#event{event_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Events).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий по статусам (active, cancelled, completed).
%%% @end
%%%-------------------------------------------------------------------
-spec count_events_by_status() -> [{atom(), non_neg_integer()}].
count_events_by_status() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
lists:foldl(fun(#event{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Events).
%%%-------------------------------------------------------------------
%%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_events_by_rating(N :: non_neg_integer()) -> [#event{}].
get_top_events_by_rating(N) ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end,
Events)),
lists:sublist(Sorted, N).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @doc Выделить из datetime только дату `{Y,M,D}`.
%%% @end %%% @end
+52 -1
View File
@@ -3,7 +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]). -export([count_reports_by_date/2, get_top_targets_by_reports/1, count_reports_by_status/0, count_reports_by_target_type/0]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -178,4 +178,55 @@ count_reports_by_date(From, To) ->
end, [], Filtered), end, [], Filtered),
lists:sort(Counts). lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб по типам цели (event, calendar, review).
%%% @end
%%%-------------------------------------------------------------------
-spec count_reports_by_target_type() -> [{atom(), non_neg_integer()}].
count_reports_by_target_type() ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
lists:foldl(fun(#report{target_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Reports).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб по статусам.
%%% @end
%%%-------------------------------------------------------------------
-spec count_reports_by_status() -> [{atom(), non_neg_integer()}].
count_reports_by_status() ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
lists:foldl(fun(#report{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Reports).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству жалоб.
%%% Возвращает список [{TargetType, TargetId, Count}], отсортированный по убыванию Count.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_reports(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_reports(N) ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
% Группируем по {TargetType, TargetId}
Dict = lists:foldl(fun(#report{target_type = Type, target_id = Id}, Acc) ->
Key = {Type, Id},
case lists:keyfind(Key, 1, Acc) of
false -> [{Key, 1} | Acc];
{Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1})
end
end, [], Reports),
% Сортируем по убыванию Count
Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)),
% Преобразуем в тройки и берём первые N
Top = lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N),
Top.
date_part({{Y,M,D}, _}) -> {Y,M,D}. date_part({{Y,M,D}, _}) -> {Y,M,D}.
+72
View File
@@ -5,6 +5,9 @@
-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]). -export([count_reviews_by_date/2]).
-export([count_reviews_by_target_type/0, count_reviews_by_status/0,
get_top_targets_by_reviews/1, get_top_targets_by_positive_reviews/1,
get_top_targets_by_negative_reviews/1]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей %% Значения по умолчанию для необязательных полей
@@ -204,6 +207,75 @@ count_reviews_by_date(From, To) ->
date_part({{Y,M,D}, _}) -> {Y,M,D}. date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт отзывов по типам цели (event, calendar).
%%% @end
%%%-------------------------------------------------------------------
-spec count_reviews_by_target_type() -> [{atom(), non_neg_integer()}].
count_reviews_by_target_type() ->
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
lists:foldl(fun(#review{target_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Reviews).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт отзывов по статусам (visible, hidden, deleted).
%%% @end
%%%-------------------------------------------------------------------
-spec count_reviews_by_status() -> [{atom(), non_neg_integer()}].
count_reviews_by_status() ->
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
lists:foldl(fun(#review{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Reviews).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по общему количеству отзывов.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_reviews(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_reviews(N) ->
aggregate_targets_by(fun(_Review) -> true end, N).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству позитивных отзывов (rating >= 4).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_positive_reviews(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_positive_reviews(N) ->
aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству негативных отзывов (rating <= 2).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_negative_reviews(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_negative_reviews(N) ->
aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N).
%% @private Вспомогательная функция агрегации целей с фильтром.
aggregate_targets_by(Pred, N) ->
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
Filtered = lists:filter(Pred, Reviews),
Dict = lists:foldl(fun(#review{target_type = Type, target_id = Id}, Acc) ->
Key = {Type, Id},
case lists:keyfind(Key, 1, Acc) of
false -> [{Key, 1} | Acc];
{Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1})
end
end, [], Filtered),
Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)),
lists:sublist([{Type, Id, Cnt} || {{Type, Id}, Cnt} <- Sorted], N).
%%%=================================================================== %%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ %%% ВНУТРЕННИЕ ФУНКЦИИ
%%%=================================================================== %%%===================================================================
+57 -3
View File
@@ -3,8 +3,10 @@
-export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]). -export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]).
-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_subscriptions/0]).
-export([count_subscriptions_by_date/2]). -export([count_subscriptions_by_date/2]).
-export([count_subscriptions_by_plan/0, count_subscriptions_by_status/0,
count_trial_subscriptions/0, get_ending_paid_subscriptions/1]).
-define(TRIAL_DAYS, 30). -define(TRIAL_DAYS, 30).
@@ -291,8 +293,8 @@ parse_iso_datetime(Other) -> Other.
%%% @doc Количество подписок. %%% @doc Количество подписок.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec count_subscription() -> non_neg_integer(). -spec count_subscriptions() -> non_neg_integer().
count_subscription() -> count_subscriptions() ->
mnesia:table_info(subscription, size). mnesia:table_info(subscription, size).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -315,3 +317,55 @@ count_subscriptions_by_date(From, To) ->
lists:sort(Counts). lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}. date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок по планам.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscriptions_by_plan() -> [{atom(), non_neg_integer()}].
count_subscriptions_by_plan() ->
Subs = mnesia:dirty_match_object(#subscription{_ = '_'}),
lists:foldl(fun(#subscription{plan = Plan}, Acc) ->
case lists:keyfind(Plan, 1, Acc) of
false -> [{Plan, 1} | Acc];
{Plan, C} -> lists:keyreplace(Plan, 1, Acc, {Plan, C+1})
end
end, [], Subs).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок по статусам.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscriptions_by_status() -> [{atom(), non_neg_integer()}].
count_subscriptions_by_status() ->
Subs = mnesia:dirty_match_object(#subscription{_ = '_'}),
lists:foldl(fun(#subscription{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Subs).
%%%-------------------------------------------------------------------
%%% @doc Количество пробных подписок (trial_used = false).
%%% @end
%%%-------------------------------------------------------------------
-spec count_trial_subscriptions() -> non_neg_integer().
count_trial_subscriptions() ->
Match = #subscription{trial_used = false, _ = '_'},
length(mnesia:dirty_match_object(Match)).
%%%-------------------------------------------------------------------
%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней.
%%% @end
%%%-------------------------------------------------------------------
-spec get_ending_paid_subscriptions(Days :: non_neg_integer()) -> [#subscription{}].
get_ending_paid_subscriptions(Days) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
ThresholdSec = NowSec + Days * 86400,
Match = #subscription{status = active, trial_used = true, _ = '_'},
ActivePaid = mnesia:dirty_match_object(Match),
lists:filter(fun(#subscription{expires_at = Exp}) ->
ExpSec = calendar:datetime_to_gregorian_seconds(Exp),
ExpSec >= NowSec andalso ExpSec =< ThresholdSec
end, ActivePaid).
+26 -1
View File
@@ -5,7 +5,7 @@
-export([count_tickets/0]). -export([count_tickets/0]).
-export([create_ticket/1]). -export([create_ticket/1]).
-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, stats/0]).
-export([update_ticket/2]). -export([update_ticket/2]).
-export([delete_ticket/1]). -export([delete_ticket/1]).
-export([count_tickets_by_date/2]). -export([count_tickets_by_date/2]).
@@ -236,6 +236,23 @@ avg_resolution_time() ->
Total / length(Closed) / 3600.0 Total / length(Closed) / 3600.0
end. end.
%% @doc Статистика по тикетам (используется в admin_handler_ticket_stats)
stats() ->
Tickets = list_all(),
#{
total => length(Tickets),
open => count_by_status(open, Tickets),
in_progress => count_by_status(in_progress, Tickets),
resolved => count_by_status(resolved, Tickets),
closed => count_by_status(closed, Tickets)
}.
%% ── функции подсчёта с нормализацией статуса ─────────────
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
count_by_status(Status, Tickets) ->
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Количество тикетов, назначенных на администратора. %%% @doc Количество тикетов, назначенных на администратора.
%%% @end %%% @end
@@ -274,6 +291,14 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%% ВНУТРЕННИЕ ФУНКЦИИ %%% ВНУТРЕННИЕ ФУНКЦИИ
%%%=================================================================== %%%===================================================================
%% @private Преобразует бинарный статус в атом, если нужно.
%% Атомы возвращает без изменений.
normalize_status(Status) when is_atom(Status) -> Status;
normalize_status(Status) when is_binary(Status) ->
try binary_to_existing_atom(Status, utf8)
catch error:badarg -> Status
end.
apply_updates(Ticket, Updates) -> apply_updates(Ticket, Updates) ->
Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end, Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end,
Ticket, Updates), Ticket, Updates),
+33
View File
@@ -6,6 +6,7 @@
-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, count_pending_users/0, count_pending_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([count_users_by_role/0, count_users_by_status/0]).
-export([create_bot/2, delete_bot/1]). -export([create_bot/2, delete_bot/1]).
%% ───────────────────────────────────────────────────────────────── %% ─────────────────────────────────────────────────────────────────
@@ -306,6 +307,38 @@ count_pending_users_by_date(From, To) ->
end, [], Filtered), end, [], Filtered),
lists:sort(Counts). lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по ролям.
%%% Возвращает список [{Role :: atom(), Count :: non_neg_integer()}].
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_role() -> [{atom(), non_neg_integer()}].
count_users_by_role() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
Dict = lists:foldl(fun(#user{role = Role}, Acc) ->
case lists:keyfind(Role, 1, Acc) of
false -> [{Role, 1} | Acc];
{Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1})
end
end, [], Users),
Dict.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по статусам.
%%% Возвращает список [{Status :: atom(), Count :: non_neg_integer()}].
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_status() -> [{atom(), non_neg_integer()}].
count_users_by_status() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
Dict = lists:foldl(fun(#user{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Users),
Dict.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @doc Выделить из datetime только дату `{Y,M,D}`.
%%% @end %%% @end
+6
View File
@@ -113,19 +113,24 @@ start_admin_http() ->
{"/v1/admin/refresh", admin_handler_refresh, []}, {"/v1/admin/refresh", admin_handler_refresh, []},
% ================== ПОЛЬЗОВАТЕЛИ ================== % ================== ПОЛЬЗОВАТЕЛИ ==================
{"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users", admin_handler_users, []},
{"/v1/admin/users/stats", admin_handler_user_stats, []},
{"/v1/admin/users/:id", admin_handler_user_by_id, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []},
{"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []}, {"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []},
% ================== КАЛЕНДАРИ ================== % ================== КАЛЕНДАРИ ==================
{"/v1/admin/calendars", admin_handler_calendars, []}, {"/v1/admin/calendars", admin_handler_calendars, []},
{"/v1/admin/calendars/stats", admin_handler_calendar_stats, []},
{"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []}, {"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []},
% ================== СОБЫТИЯ ================== % ================== СОБЫТИЯ ==================
{"/v1/admin/events", admin_handler_events, []}, {"/v1/admin/events", admin_handler_events, []},
{"/v1/admin/events/stats", admin_handler_event_stats, []},
{"/v1/admin/events/:id", admin_handler_event_by_id, []}, {"/v1/admin/events/:id", admin_handler_event_by_id, []},
% ================== ОТЧЁТЫ ================== % ================== ОТЧЁТЫ ==================
{"/v1/admin/reports", admin_handler_reports, []}, {"/v1/admin/reports", admin_handler_reports, []},
{"/v1/admin/reports/stats", admin_handler_report_stats, []},
{"/v1/admin/reports/:id", admin_handler_report_by_id, []}, {"/v1/admin/reports/:id", admin_handler_report_by_id, []},
% ================== ОТЗЫВЫ ================== % ================== ОТЗЫВЫ ==================
{"/v1/admin/reviews", admin_handler_reviews, []}, {"/v1/admin/reviews", admin_handler_reviews, []},
{"/v1/admin/reviews/stats", admin_handler_reviews_stats, []},
{"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []}, {"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []},
% ================== БАН-СЛОВА ================== % ================== БАН-СЛОВА ==================
{"/v1/admin/banned-words/batch", admin_handler_banned_words, []}, {"/v1/admin/banned-words/batch", admin_handler_banned_words, []},
@@ -137,6 +142,7 @@ start_admin_http() ->
{"/v1/admin/tickets", admin_handler_tickets, []}, {"/v1/admin/tickets", admin_handler_tickets, []},
% ================== ПОДПИСКИ ================== % ================== ПОДПИСКИ ==================
{"/v1/admin/subscriptions", admin_handler_subscriptions, []}, {"/v1/admin/subscriptions", admin_handler_subscriptions, []},
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []}, {"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
% ================== Управление ролями (только для superadmin) ================== % ================== Управление ролями (только для superadmin) ==================
{"/v1/admin/me", admin_handler_me, []}, {"/v1/admin/me", admin_handler_me, []},
@@ -0,0 +1,107 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик статистики по календарям.
%%% GET /v1/admin/calendars/stats
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_calendar_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_calendar_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[ #{
path => <<"/v1/admin/calendars/stats">>,
method => <<"GET">>,
description => <<"Calendar statistics">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{
description => <<"Calendar statistics object">>,
content => #{<<"application/json">> => #{schema => calendar_stats_schema()}}
}
}
} ].
calendar_stats_schema() ->
#{
type => object,
properties => #{
<<"total_calendars">> => #{type => integer},
<<"calendars_by_type">> => #{
type => object,
additionalProperties => #{type => integer}
},
<<"calendars_by_status">> => #{
type => object,
additionalProperties => #{type => integer}
},
<<"top_calendars_by_reviews">> => #{
type => array,
items => calendar_rank_schema()
},
<<"top_calendars_by_positive_reviews">> => #{
type => array,
items => calendar_rank_schema()
},
<<"top_calendars_by_negative_reviews">> => #{
type => array,
items => calendar_rank_schema()
},
<<"top_calendars_by_rating">> => #{
type => array,
items => calendar_rank_schema()
}
}
}.
calendar_rank_schema() ->
#{
type => object,
properties => #{
<<"id">> => #{type => string},
<<"title">> => #{type => string},
<<"rating_avg">> => #{type => number, format => float},
<<"review_count">> => #{type => integer}
}
}.
%%% Internal functions
get_calendar_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Total = core_calendar:count_calendars(),
ByType = core_calendar:count_calendars_by_type(),
ByStatus = core_calendar:count_calendars_by_status(),
TopReviews = core_calendar:get_top_calendars_by_reviews(10),
TopPositive = core_calendar:get_top_calendars_by_positive_reviews(10),
TopNegative = core_calendar:get_top_calendars_by_negative_reviews(10),
TopRating = core_calendar:get_top_calendars_by_rating(10),
Stats = #{
<<"total_calendars">> => Total,
<<"calendars_by_type">> => maps:from_list([{atom_to_binary(T, utf8), C} || {T, C} <- ByType]),
<<"calendars_by_status">> => maps:from_list([{atom_to_binary(S, utf8), C} || {S, C} <- ByStatus]),
<<"top_calendars_by_reviews">> => format_calendar_list(TopReviews),
<<"top_calendars_by_positive_reviews">> => format_calendar_list(TopPositive),
<<"top_calendars_by_negative_reviews">> => format_calendar_list(TopNegative),
<<"top_calendars_by_rating">> => format_calendar_list(TopRating)
},
handler_utils:send_json(Req1, 200, Stats);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
format_calendar_list(List) ->
lists:map(fun(#calendar{id = Id, title = Title, rating_avg = RAvg}) ->
#{<<"id">> => Id, <<"title">> => Title, <<"rating_avg">> => RAvg}
end, List).
@@ -0,0 +1,100 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик статистики по событиям.
%%% GET /v1/admin/events/stats возвращает общее количество,
%%% распределение по типам, статусам и топ-10 по рейтингу.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_event_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_event_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/events/stats">>,
method => <<"GET">>,
description => <<"Get detailed event statistics (total, by type, by status, top 10 by rating)">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{
description => <<"Event statistics object">>,
content => #{<<"application/json">> => #{schema => event_stats_schema()}}
},
403 => #{description => <<"Admin access required">>}
}
}
].
event_stats_schema() ->
#{
type => object,
properties => #{
<<"total_events">> => #{type => integer, description => <<"Total number of events (master, active)">>},
<<"events_by_type">> => #{
type => object,
description => <<"Number of events grouped by type (single, recurring)">>,
additionalProperties => #{type => integer}
},
<<"events_by_status">> => #{
type => object,
description => <<"Number of events grouped by status (active, cancelled, completed)">>,
additionalProperties => #{type => integer}
},
<<"top_events_by_rating">> => #{
type => array,
description => <<"Top 10 events by average rating">>,
items => #{
type => object,
properties => #{
<<"id">> => #{type => string},
<<"title">> => #{type => string},
<<"rating_avg">> => #{type => number, format => float},
<<"rating_count">> => #{type => integer}
}
}
}
}
}.
%%% Internal functions
%% @doc GET /v1/admin/events/stats статистика по событиям.
-spec get_event_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_event_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Total = core_event:count_events(),
ByType = core_event:count_events_by_type(),
ByStatus = core_event:count_events_by_status(),
Top10 = core_event:get_top_events_by_rating(10),
Stats = #{
<<"total_events">> => Total,
<<"events_by_type">> => maps:from_list([{atom_to_binary(Type, utf8), Count} || {Type, Count} <- ByType]),
<<"events_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]),
<<"top_events_by_rating">> => lists:map(fun event_to_map/1, Top10)
},
handler_utils:send_json(Req1, 200, Stats);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
event_to_map(Event) ->
#{
<<"id">> => Event#event.id,
<<"title">> => Event#event.title,
<<"rating_avg">> => Event#event.rating_avg,
<<"rating_count">> => Event#event.rating_count
}.
@@ -0,0 +1,97 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик статистики по жалобам.
%%% GET /v1/admin/reports/stats возвращает общее количество,
%%% распределение по типам цели, статусам и топ-10 целей по жалобам.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_report_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_report_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/reports/stats">>,
method => <<"GET">>,
description => <<"Get detailed report statistics (total, by target type, by status, top 10 targets by reports)">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{
description => <<"Report statistics object">>,
content => #{<<"application/json">> => #{schema => report_stats_schema()}}
},
403 => #{description => <<"Admin access required">>}
}
}
].
report_stats_schema() ->
#{
type => object,
properties => #{
<<"total_reports">> => #{type => integer, description => <<"Total number of reports">>},
<<"reports_by_target_type">> => #{
type => object,
description => <<"Number of reports grouped by target type (event, calendar, review)">>,
additionalProperties => #{type => integer}
},
<<"reports_by_status">> => #{
type => object,
description => <<"Number of reports grouped by status (pending, reviewed, dismissed)">>,
additionalProperties => #{type => integer}
},
<<"top_targets_by_reports">> => #{
type => array,
description => <<"Top 10 targets by number of reports">>,
items => #{
type => object,
properties => #{
<<"target_type">> => #{type => string},
<<"target_id">> => #{type => string},
<<"report_count">> => #{type => integer}
}
}
}
}
}.
%%% Internal functions
%% @doc GET /v1/admin/reports/stats статистика по жалобам.
-spec get_report_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_report_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Total = core_report:count_reports(),
ByTargetType = core_report:count_reports_by_target_type(),
ByStatus = core_report:count_reports_by_status(),
Top10 = core_report:get_top_targets_by_reports(10),
Stats = #{
<<"total_reports">> => Total,
<<"reports_by_target_type">> => maps:from_list([{atom_to_binary(Type, utf8), Count} || {Type, Count} <- ByTargetType]),
<<"reports_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]),
<<"top_targets_by_reports">> => lists:map(fun({TargetType, TargetId, Count}) ->
#{
<<"target_type">> => atom_to_binary(TargetType, utf8),
<<"target_id">> => TargetId,
<<"report_count">> => Count
}
end, Top10)
},
handler_utils:send_json(Req1, 200, Stats);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
@@ -0,0 +1,116 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик статистики по отзывам.
%%% GET /v1/admin/reviews/stats возвращает общее количество,
%%% распределение по типам цели, статусам, топ-10 целей по отзывам
%%% (всего, позитивных, негативных).
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_reviews_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_review_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/reviews/stats">>,
method => <<"GET">>,
description => <<"Get detailed review statistics (total, by target type, by status, top targets by reviews/positive/negative)">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{
description => <<"Review statistics object">>,
content => #{<<"application/json">> => #{schema => review_stats_schema()}}
},
403 => #{description => <<"Admin access required">>}
}
}
].
review_stats_schema() ->
#{
type => object,
properties => #{
<<"total_reviews">> => #{type => integer, description => <<"Total number of reviews">>},
<<"reviews_by_target_type">> => #{
type => object,
description => <<"Number of reviews grouped by target type (event, calendar)">>,
additionalProperties => #{type => integer}
},
<<"reviews_by_status">> => #{
type => object,
description => <<"Number of reviews grouped by status (visible, hidden, deleted)">>,
additionalProperties => #{type => integer}
},
<<"top_targets_by_reviews">> => #{
type => array,
description => <<"Top 10 targets by total number of reviews">>,
items => target_review_count_schema()
},
<<"top_targets_by_positive_reviews">> => #{
type => array,
description => <<"Top 10 targets by positive reviews (rating >= 4)">>,
items => target_review_count_schema()
},
<<"top_targets_by_negative_reviews">> => #{
type => array,
description => <<"Top 10 targets by negative reviews (rating <= 2)">>,
items => target_review_count_schema()
}
}
}.
target_review_count_schema() ->
#{
type => object,
properties => #{
<<"target_type">> => #{type => string},
<<"target_id">> => #{type => string},
<<"review_count">> => #{type => integer}
}
}.
%%% Internal functions
%% @doc GET /v1/admin/reviews/stats статистика по отзывам.
-spec get_review_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_review_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Total = core_review:count_reviews(),
ByTargetType = core_review:count_reviews_by_target_type(),
ByStatus = core_review:count_reviews_by_status(),
TopAll = core_review:get_top_targets_by_reviews(10),
TopPositive = core_review:get_top_targets_by_positive_reviews(10),
TopNegative = core_review:get_top_targets_by_negative_reviews(10),
Stats = #{
<<"total_reviews">> => Total,
<<"reviews_by_target_type">> => maps:from_list([{atom_to_binary(Type, utf8), Count} || {Type, Count} <- ByTargetType]),
<<"reviews_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]),
<<"top_targets_by_reviews">> => lists:map(fun target_to_map/1, TopAll),
<<"top_targets_by_positive_reviews">> => lists:map(fun target_to_map/1, TopPositive),
<<"top_targets_by_negative_reviews">> => lists:map(fun target_to_map/1, TopNegative)
},
handler_utils:send_json(Req1, 200, Stats);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
target_to_map({TargetType, TargetId, Count}) ->
#{
<<"target_type">> => atom_to_binary(TargetType, utf8),
<<"target_id">> => TargetId,
<<"review_count">> => Count
}.
+7 -4
View File
@@ -47,15 +47,18 @@ stats_schema() ->
type => object, type => object,
properties => #{ properties => #{
<<"users_total">> => #{type => integer, description => <<"Total number of users">>}, <<"users_total">> => #{type => integer, description => <<"Total number of users">>},
<<"pending_users_total">> => #{type => integer, description => <<"Number of users awaiting email verification">>},
<<"events_total">> => #{type => integer, description => <<"Total number of events">>}, <<"events_total">> => #{type => integer, description => <<"Total number of events">>},
<<"reviews_total">> => #{type => integer, description => <<"Total number of reviews">>},
<<"calendars_total">> => #{type => integer, description => <<"Total number of calendars">>}, <<"calendars_total">> => #{type => integer, description => <<"Total number of calendars">>},
<<"reports_total">> => #{type => integer, description => <<"Total number of reports">>}, <<"reviews_total">> => #{type => integer, description => <<"Total number of reviews">>},
<<"reports_total">> => #{type => integer, description => <<"Total number of reports (all statuses)">>},
<<"reports_pending">> => #{type => integer, description => <<"Number of pending reports">>},
<<"reports_reviewed">> => #{type => integer, description => <<"Number of reviewed reports">>},
<<"reports_dismissed">> => #{type => integer, description => <<"Number of dismissed reports">>},
<<"tickets_total">> => #{type => integer, description => <<"Total number of tickets">>}, <<"tickets_total">> => #{type => integer, description => <<"Total number of tickets">>},
<<"tickets_open">> => #{type => integer, description => <<"Number of open tickets">>}, <<"tickets_open">> => #{type => integer, description => <<"Number of open tickets">>},
<<"subscriptions_total">> => #{type => integer, description => <<"Total number of subscriptions">>}, <<"subscriptions_total">> => #{type => integer, description => <<"Total number of subscriptions">>},
<<"active_subscriptions">> => #{type => integer, description => <<"Number of active subscriptions">>}, <<"trial_subscriptions">> => #{type => integer, description => <<"Number of trial 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">>}, <<"avg_ticket_resolution_h">> => #{type => number, format => float, description => <<"Average ticket resolution time in hours">>},
<<"registrations_by_day">> => #{ <<"registrations_by_day">> => #{
type => array, type => array,
@@ -0,0 +1,102 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик статистики по подпискам.
%%% GET /v1/admin/subscriptions/stats возвращает общее количество,
%%% распределение по планам, статусам, пробные, заканчивающиеся платные.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_subscription_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_subscription_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/subscriptions/stats">>,
method => <<"GET">>,
description => <<"Get detailed subscription statistics (total, by plan, by status, trial count, ending paid subscriptions)">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{
description => <<"Subscription statistics object">>,
content => #{<<"application/json">> => #{schema => subscription_stats_schema()}}
},
403 => #{description => <<"Admin access required">>}
}
}
].
subscription_stats_schema() ->
#{
type => object,
properties => #{
<<"total_subscriptions">> => #{type => integer, description => <<"Total number of subscriptions">>},
<<"subscriptions_by_plan">> => #{
type => object,
description => <<"Number of subscriptions grouped by plan">>,
additionalProperties => #{type => integer}
},
<<"subscriptions_by_status">> => #{
type => object,
description => <<"Number of subscriptions grouped by status">>,
additionalProperties => #{type => integer}
},
<<"trial_subscriptions">> => #{type => integer, description => <<"Number of subscriptions in trial period (trial_used = false)">>},
<<"ending_paid_subscriptions">> => #{
type => array,
description => <<"Paid subscriptions expiring within 30 days">>,
items => #{
type => object,
properties => #{
<<"id">> => #{type => string},
<<"user_id">> => #{type => string},
<<"plan">> => #{type => string},
<<"expires_at">> => #{type => string, format => <<"date-time">>}
}
}
}
}
}.
%%% Internal functions
%% @doc GET /v1/admin/subscriptions/stats статистика по подпискам.
-spec get_subscription_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_subscription_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Total = core_subscription:count_subscriptions(),
ByPlan = core_subscription:count_subscriptions_by_plan(),
ByStatus = core_subscription:count_subscriptions_by_status(),
Trial = core_subscription:count_trial_subscriptions(),
EndingPaid = core_subscription:get_ending_paid_subscriptions(30),
Stats = #{
<<"total_subscriptions">> => Total,
<<"subscriptions_by_plan">> => maps:from_list([{atom_to_binary(Plan, utf8), Count} || {Plan, Count} <- ByPlan]),
<<"subscriptions_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]),
<<"trial_subscriptions">> => Trial,
<<"ending_paid_subscriptions">> => lists:map(fun(Sub) ->
#{
<<"id">> => Sub#subscription.id,
<<"user_id">> => Sub#subscription.user_id,
<<"plan">> => atom_to_binary(Sub#subscription.plan, utf8),
<<"expires_at">> => handler_utils:datetime_to_iso8601(Sub#subscription.expires_at)
}
end, EndingPaid)
},
handler_utils:send_json(Req1, 200, Stats);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
@@ -1,12 +1,10 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Административный обработчик для получения статистики по тикетам. %%% @doc Административный обработчик статистики по тикетам.
%%% GET возвращает агрегированную статистику тикетов %%% GET возвращает агрегированные данные по тикетам.
%%% (количество по статусам: open, in_progress, resolved, closed).
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_ticket_stats). -module(admin_handler_ticket_stats).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -16,7 +14,7 @@
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_stats(Req); <<"GET">> -> get_ticket_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
@@ -27,36 +25,39 @@ trails() ->
#{ #{
path => <<"/v1/admin/tickets/stats">>, path => <<"/v1/admin/tickets/stats">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get ticket statistics (admin)">>, description => <<"Get ticket statistics">>,
tags => [<<"Tickets">>], tags => [<<"Tickets">>],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Ticket statistics">>, description => <<"Ticket statistics">>,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => ticket_stats_schema()}}
type => object,
properties => #{
open => #{type => integer, description => <<"Number of open tickets">>},
in_progress => #{type => integer, description => <<"Number of tickets in progress">>},
resolved => #{type => integer, description => <<"Number of resolved tickets">>},
closed => #{type => integer, description => <<"Number of closed tickets">>},
total => #{type => integer, description => <<"Total number of tickets">>}
} }
}}}
},
403 => #{description => <<"Admin access required">>}
} }
} }
]. ].
ticket_stats_schema() ->
#{
type => object,
properties => #{
open => #{type => integer, description => <<"Number of open tickets">>},
in_progress => #{type => integer, description => <<"Number of in-progress tickets">>},
resolved => #{type => integer, description => <<"Number of resolved tickets">>},
closed => #{type => integer, description => <<"Number of closed tickets">>},
total_tickets => #{type => integer, description => <<"Total number of tickets">>},
total_errors => #{type => integer, description => <<"Total error occurrences">>}
}
}.
%%% Internal functions %%% Internal functions
%% @doc Получить статистику тикетов. Доступно только администраторам. %% @doc GET /v1/admin/tickets/stats получить статистику по тикетам.
-spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec get_ticket_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_stats(Req) -> get_ticket_stats(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
Stats = core_ticket:stats(), Stats = logic_ticket:get_statistics(AdminId),
handler_utils:send_json(Req1, 200, Stats); handler_utils:send_json(Req1, 200, Stats);
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
@@ -0,0 +1,80 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик статистики по пользователям.
%%% GET /v1/admin/users/stats возвращает общее количество,
%%% распределение по ролям, статусам и число неподтверждённых.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_user_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_user_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/users/stats">>,
method => <<"GET">>,
description => <<"Get detailed user statistics (total, by role, by status, pending)">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{
description => <<"User statistics object">>,
content => #{<<"application/json">> => #{schema => user_stats_schema()}}
},
403 => #{description => <<"Admin access required">>}
}
}
].
user_stats_schema() ->
#{
type => object,
properties => #{
<<"total_users">> => #{type => integer, description => <<"Total number of users">>},
<<"users_by_role">> => #{
type => object,
description => <<"Number of users grouped by role">>,
additionalProperties => #{type => integer}
},
<<"users_by_status">> => #{
type => object,
description => <<"Number of users grouped by status">>,
additionalProperties => #{type => integer}
},
<<"pending_users">> => #{type => integer, description => <<"Number of unverified users">>}
}
}.
%%% Internal functions
%% @doc GET /v1/admin/users/stats статистика по пользователям.
-spec get_user_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_user_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Total = core_user:count_users(),
ByRole = core_user:count_users_by_role(),
ByStatus = core_user:count_users_by_status(),
Pending = core_user:count_pending_users(),
Stats = #{
<<"total_users">> => Total,
<<"users_by_role">> => maps:from_list([{atom_to_binary(Role, utf8), Count} || {Role, Count} <- ByRole]),
<<"users_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]),
<<"pending_users">> => Pending
},
handler_utils:send_json(Req1, 200, Stats);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
+12 -9
View File
@@ -1,10 +1,3 @@
%%%-------------------------------------------------------------------
%%% @doc Модуль сбора статистики для административной панели.
%%%
%%% Предоставляет функции `get_stats/2` и `get_stats/4`, возвращающие
%%% агрегированные показатели в зависимости от роли администратора.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_stats). -module(logic_stats).
-export([get_stats/2, get_stats/4]). -export([get_stats/2, get_stats/4]).
-include("records.hrl"). -include("records.hrl").
@@ -53,9 +46,14 @@ build_superadmin_stats(From, To) ->
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(),
reports_total => core_report:count_reports_by_status(pending), reports_total => core_report:count_reports(), % все жалобы
reports_pending => core_report:count_reports_by_status(pending),
reports_reviewed => core_report:count_reports_by_status(reviewed),
reports_dismissed => core_report:count_reports_by_status(dismissed),
tickets_open => core_ticket:count_tickets_by_status(open), tickets_open => core_ticket:count_tickets_by_status(open),
tickets_total => length(core_ticket:list_all()), tickets_total => length(core_ticket:list_all()),
subscriptions_total => core_subscription:count_subscriptions(),
trial_subscriptions => core_subscription:count_trial_subscriptions(),
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)),
@@ -80,9 +78,14 @@ build_admin_stats(From, To) ->
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(),
reports_total => core_report:count_reports_by_status(pending), reports_total => core_report:count_reports(),
reports_pending => core_report:count_reports_by_status(pending),
reports_reviewed => core_report:count_reports_by_status(reviewed),
reports_dismissed => core_report:count_reports_by_status(dismissed),
tickets_open => core_ticket:count_tickets_by_status(open), tickets_open => core_ticket:count_tickets_by_status(open),
tickets_total => length(core_ticket:list_all()), tickets_total => length(core_ticket:list_all()),
subscriptions_total => core_subscription:count_subscriptions(),
trial_subscriptions => core_subscription:count_trial_subscriptions(),
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)),
+12 -6
View File
@@ -12,22 +12,27 @@ admin() ->
admin_handler_health, admin_handler_health,
admin_handler_stats, admin_handler_stats,
admin_handler_login, admin_handler_login,
admin_handler_refresh, % добавлен admin_handler_refresh,
% ================== ПОЛЬЗОВАТЕЛИ ================== % ================== ПОЛЬЗОВАТЕЛИ ==================
admin_handler_users, admin_handler_users,
admin_handler_user_by_id, admin_handler_user_by_id,
admin_handler_user_verification_token, % добавлен admin_handler_user_verification_token,
admin_handler_user_stats,
% ================== КАЛЕНДАРИ ================== % ================== КАЛЕНДАРИ ==================
admin_handler_calendars, % добавлен admin_handler_calendars,
admin_handler_calendar_by_id, % добавлен admin_handler_calendar_stats,
admin_handler_calendar_by_id,
% ================== СОБЫТИЯ ================== % ================== СОБЫТИЯ ==================
admin_handler_events, admin_handler_events,
admin_handler_event_stats,
admin_handler_event_by_id, admin_handler_event_by_id,
% ================== ОТЧЁТЫ ================== % ================== ОТЧЁТЫ ==================
admin_handler_reports, admin_handler_reports,
admin_handler_report_stats,
admin_handler_report_by_id, admin_handler_report_by_id,
% ================== ОТЗЫВЫ ================== % ================== ОТЗЫВЫ ==================
admin_handler_reviews, admin_handler_reviews,
admin_handler_reviews_stats,
admin_handler_reviews_by_id, admin_handler_reviews_by_id,
% ================== БАН-СЛОВА ================== % ================== БАН-СЛОВА ==================
admin_handler_banned_words, admin_handler_banned_words,
@@ -37,13 +42,14 @@ admin() ->
admin_handler_tickets, admin_handler_tickets,
% ================== ПОДПИСКИ ================== % ================== ПОДПИСКИ ==================
admin_handler_subscriptions, admin_handler_subscriptions,
admin_handler_subscription_stats,
admin_handler_subscriptions_by_id, admin_handler_subscriptions_by_id,
% ================== МОДЕРАЦИЯ (общий маршрут) ================== % ================== МОДЕРАЦИЯ (общий маршрут) ==================
admin_handler_moderation, admin_handler_moderation,
% ================== Управление ролями (только для superadmin) ================== % ================== Управление ролями (только для superadmin) ==================
admin_handler_me, admin_handler_me,
admin_handler_admins, admin_handler_admins,
admin_handler_admins_by_id, % добавлен admin_handler_admins_by_id,
admin_handler_audit admin_handler_audit
], ],
lists:flatmap(fun trails_from_module/1, Modules). lists:flatmap(fun trails_from_module/1, Modules).
@@ -59,7 +65,7 @@ user() ->
handler_register, handler_register,
handler_login, handler_login,
handler_refresh, handler_refresh,
handler_verify, % добавлен handler_verify,
handler_booking_by_id, handler_booking_by_id,
handler_bookings, handler_bookings,
handler_calendar_by_id, handler_calendar_by_id,
+147 -12
View File
@@ -3,13 +3,24 @@
%%% %%%
%%% Покрывает эндпоинты: %%% Покрывает эндпоинты:
%%% GET /v1/admin/stats %%% GET /v1/admin/stats
%%% GET /v1/admin/users/stats
%%% GET /v1/admin/events/stats
%%% GET /v1/admin/reports/stats
%%% GET /v1/admin/reviews/stats
%%% GET /v1/admin/subscriptions/stats
%%% GET /v1/admin/calendars/stats
%%% %%%
%%% Проверяет: %%% Проверяет:
%%% - получение статистики для всех четырёх ролей администраторов %%% - получение общей статистики для всех ролей администраторов
%%% - для superadmin и admin наличие ключевых метрик, включая %%% - для superadmin и admin наличие ключевых метрик, включая pending_users_total, *_by_day
%%% pending_users_total, *_by_day
%%% - для moderator и support ответ непустой %%% - для moderator и support ответ непустой
%%% - работу с фильтром по датам (from, to) %%% - работу с фильтром по датам (from, to)
%%% - статистику по пользователям (users/stats)
%%% - статистику по событиям (events/stats)
%%% - статистику по жалобам (reports/stats)
%%% - статистику по отзывам (reviews/stats)
%%% - статистику по подпискам (subscriptions/stats)
%%% - статистику по календарям (calendars/stats)
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_stats_tests). -module(admin_stats_tests).
@@ -27,6 +38,31 @@ test() ->
ModerToken = api_test_runner:get_moderator_token(), ModerToken = api_test_runner:get_moderator_token(),
SupportToken = api_test_runner:get_support_token(), SupportToken = api_test_runner:get_support_token(),
% Создаём тестовые данные для ненулевой статистики
UserToken = api_test_runner:get_user_token(),
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"StatsCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Stats Event">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
% Бронируем и подтверждаем, чтобы можно было оставить отзыв
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, UserToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken,
#{action => <<"confirm">>}),
% Оставляем отзыв
api_test_runner:client_post(<<"/v1/reviews">>, UserToken,
#{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>}),
% Жалоба
api_test_runner:client_post(<<"/v1/reports">>, UserToken,
#{<<"target_type">> => <<"event">>, <<"target_id">> => EventId, <<"reason">> => <<"Test">>}),
% Подписка
SubUserToken = api_test_runner:get_user_token(),
api_test_runner:client_post(<<"/v1/subscription">>, SubUserToken,
#{<<"action">> => <<"start_trial">>}),
% Общая статистика
test_stats_for_role("Superadmin", SuperToken, strict), test_stats_for_role("Superadmin", SuperToken, strict),
test_stats_for_role("Admin", AdminToken, strict), test_stats_for_role("Admin", AdminToken, strict),
test_stats_for_role("Moderator", ModerToken, loose), test_stats_for_role("Moderator", ModerToken, loose),
@@ -34,6 +70,14 @@ test() ->
test_stats_with_dates(SuperToken), test_stats_with_dates(SuperToken),
% Детальная статистика
test_user_stats(SuperToken),
test_event_stats(SuperToken),
test_report_stats(SuperToken),
test_review_stats(SuperToken),
test_subscription_stats(SuperToken),
test_calendar_stats(SuperToken),
ct:pal("=== All admin stats tests passed ==="), ct:pal("=== All admin stats tests passed ==="),
ok. ok.
@@ -41,9 +85,6 @@ test() ->
%%% Тестовые функции %%% Тестовые функции
%%%=================================================================== %%%===================================================================
%% @doc Проверяет получение статистики для конкретной роли.
%% strict ожидаем ключи users_total/events_total/pending_users_total и *_by_day
%% 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]),
@@ -51,18 +92,32 @@ test_stats_for_role(RoleName, Token, Strictness) ->
?assert(is_map(Stats)), ?assert(is_map(Stats)),
case Strictness of case Strictness of
strict -> strict ->
HasUsers = maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats), % Тотальные значения
HasEvents = maps:is_key(<<"events_total">>, Stats) orelse maps:is_key(<<"events">>, Stats), ?assert(maps:is_key(<<"users_total">>, Stats)),
?assert(HasUsers orelse HasEvents),
% Проверяем наличие новых ключей
?assert(maps:is_key(<<"pending_users_total">>, Stats)), ?assert(maps:is_key(<<"pending_users_total">>, Stats)),
?assert(maps:is_key(<<"events_total">>, Stats)),
?assert(maps:is_key(<<"calendars_total">>, Stats)),
?assert(maps:is_key(<<"reviews_total">>, Stats)),
?assert(maps:is_key(<<"reports_total">>, Stats)),
?assert(maps:is_key(<<"reports_pending">>, Stats)),
?assert(maps:is_key(<<"reports_reviewed">>, Stats)),
?assert(maps:is_key(<<"reports_dismissed">>, Stats)),
?assert(maps:is_key(<<"tickets_total">>, Stats)),
?assert(maps:is_key(<<"tickets_open">>, Stats)),
?assert(maps:is_key(<<"subscriptions_total">>, Stats)),
?assert(maps:is_key(<<"trial_subscriptions">>, Stats)),
% Массивы по дням
?assert(maps:is_key(<<"registrations_by_day">>, Stats)),
?assert(maps:is_key(<<"events_by_day">>, Stats)),
?assert(maps:is_key(<<"reviews_by_day">>, Stats)), ?assert(maps:is_key(<<"reviews_by_day">>, Stats)),
?assert(maps:is_key(<<"calendars_by_day">>, Stats)), ?assert(maps:is_key(<<"calendars_by_day">>, Stats)),
?assert(maps:is_key(<<"reports_by_day">>, Stats)), ?assert(maps:is_key(<<"reports_by_day">>, Stats)),
?assert(maps:is_key(<<"tickets_by_day">>, Stats)), ?assert(maps:is_key(<<"tickets_by_day">>, Stats)),
?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)), ?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)),
?assert(maps:is_key(<<"pending_users_by_day">>, Stats)), ?assert(maps:is_key(<<"pending_users_by_day">>, Stats)),
% Проверяем, что *_by_day являются списками % Проверка, что они действительно списки
?assert(is_list(maps:get(<<"registrations_by_day">>, Stats))),
?assert(is_list(maps:get(<<"events_by_day">>, Stats))),
?assert(is_list(maps:get(<<"reviews_by_day">>, Stats))), ?assert(is_list(maps:get(<<"reviews_by_day">>, Stats))),
?assert(is_list(maps:get(<<"calendars_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(<<"reports_by_day">>, Stats))),
@@ -74,7 +129,6 @@ test_stats_for_role(RoleName, Token, Strictness) ->
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=... проверяет фильтрацию по датам.
-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"),
@@ -85,3 +139,84 @@ test_stats_with_dates(Token) ->
?assert(is_map(Stats)), ?assert(is_map(Stats)),
?assert(maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats)), ?assert(maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats)),
ct:pal(" OK"). ct:pal(" OK").
%% @doc Тестирует эндпоинт GET /v1/admin/users/stats.
-spec test_user_stats(binary()) -> ok.
test_user_stats(Token) ->
ct:pal(" TEST: User statistics"),
Stats = api_test_runner:admin_get(<<"/v1/admin/users/stats">>, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"total_users">>, Stats)),
?assert(maps:is_key(<<"users_by_role">>, Stats)),
?assert(maps:is_key(<<"users_by_status">>, Stats)),
?assert(maps:is_key(<<"pending_users">>, Stats)),
ct:pal(" OK: total=~p, pending=~p", [maps:get(<<"total_users">>, Stats), maps:get(<<"pending_users">>, Stats)]).
%% @doc Тестирует эндпоинт GET /v1/admin/events/stats.
-spec test_event_stats(binary()) -> ok.
test_event_stats(Token) ->
ct:pal(" TEST: Event statistics"),
Stats = api_test_runner:admin_get(<<"/v1/admin/events/stats">>, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"total_events">>, Stats)),
?assert(maps:is_key(<<"events_by_type">>, Stats)),
?assert(maps:is_key(<<"events_by_status">>, Stats)),
?assert(maps:is_key(<<"top_events_by_rating">>, Stats)),
?assert(is_list(maps:get(<<"top_events_by_rating">>, Stats))),
ct:pal(" OK: total=~p", [maps:get(<<"total_events">>, Stats)]).
%% @doc Тестирует эндпоинт GET /v1/admin/reports/stats.
-spec test_report_stats(binary()) -> ok.
test_report_stats(Token) ->
ct:pal(" TEST: Report statistics"),
Stats = api_test_runner:admin_get(<<"/v1/admin/reports/stats">>, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"total_reports">>, Stats)),
?assert(maps:is_key(<<"reports_by_target_type">>, Stats)),
?assert(maps:is_key(<<"reports_by_status">>, Stats)),
?assert(maps:is_key(<<"top_targets_by_reports">>, Stats)),
?assert(is_list(maps:get(<<"top_targets_by_reports">>, Stats))),
ct:pal(" OK: total=~p", [maps:get(<<"total_reports">>, Stats)]).
%% @doc Тестирует эндпоинт GET /v1/admin/reviews/stats.
-spec test_review_stats(binary()) -> ok.
test_review_stats(Token) ->
ct:pal(" TEST: Review statistics"),
Stats = api_test_runner:admin_get(<<"/v1/admin/reviews/stats">>, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"total_reviews">>, Stats)),
?assert(maps:is_key(<<"reviews_by_target_type">>, Stats)),
?assert(maps:is_key(<<"reviews_by_status">>, Stats)),
?assert(maps:is_key(<<"top_targets_by_reviews">>, Stats)),
?assert(maps:is_key(<<"top_targets_by_positive_reviews">>, Stats)),
?assert(maps:is_key(<<"top_targets_by_negative_reviews">>, Stats)),
ct:pal(" OK: total=~p", [maps:get(<<"total_reviews">>, Stats)]).
%% @doc Тестирует эндпоинт GET /v1/admin/subscriptions/stats.
-spec test_subscription_stats(binary()) -> ok.
test_subscription_stats(Token) ->
ct:pal(" TEST: Subscription statistics"),
Stats = api_test_runner:admin_get(<<"/v1/admin/subscriptions/stats">>, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"total_subscriptions">>, Stats)),
?assert(maps:is_key(<<"subscriptions_by_plan">>, Stats)),
?assert(maps:is_key(<<"subscriptions_by_status">>, Stats)),
?assert(maps:is_key(<<"trial_subscriptions">>, Stats)),
?assert(maps:is_key(<<"ending_paid_subscriptions">>, Stats)),
?assert(is_list(maps:get(<<"ending_paid_subscriptions">>, Stats))),
ct:pal(" OK: total=~p, trial=~p", [maps:get(<<"total_subscriptions">>, Stats), maps:get(<<"trial_subscriptions">>, Stats)]).
%% @doc Тестирует эндпоинт GET /v1/admin/calendars/stats.
-spec test_calendar_stats(binary()) -> ok.
test_calendar_stats(Token) ->
ct:pal(" TEST: Calendar statistics"),
Stats = api_test_runner:admin_get(<<"/v1/admin/calendars/stats">>, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"total_calendars">>, Stats)),
?assert(maps:is_key(<<"calendars_by_type">>, Stats)),
?assert(maps:is_key(<<"calendars_by_status">>, Stats)),
?assert(maps:is_key(<<"top_calendars_by_reviews">>, Stats)),
?assert(maps:is_key(<<"top_calendars_by_positive_reviews">>, Stats)),
?assert(maps:is_key(<<"top_calendars_by_negative_reviews">>, Stats)),
?assert(maps:is_key(<<"top_calendars_by_rating">>, Stats)),
ct:pal(" OK: total=~p", [maps:get(<<"total_calendars">>, Stats)]).