Расширена статистика для разделов #22
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
-export([count_calendars/0, list_all/0]).
|
||||
-export([freeze/2, unfreeze/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}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @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)]].
|
||||
|
||||
%%%===================================================================
|
||||
%%% ВНУТРЕННИЕ ФУНКЦИИ
|
||||
%%%===================================================================
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
-export([count_events/0, count_events_by_date/2]).
|
||||
-export([freeze/2, unfreeze/2]).
|
||||
-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),
|
||||
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}`.
|
||||
%%% @end
|
||||
|
||||
@@ -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([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_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),
|
||||
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}.
|
||||
@@ -5,6 +5,9 @@
|
||||
-export([get_average_rating/2, has_user_reviewed/3]).
|
||||
-export([count_reviews/0, list_all/0]).
|
||||
-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}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @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).
|
||||
|
||||
%%%===================================================================
|
||||
%%% ВНУТРЕННИЕ ФУНКЦИИ
|
||||
%%%===================================================================
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
-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([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_plan/0, count_subscriptions_by_status/0,
|
||||
count_trial_subscriptions/0, get_ending_paid_subscriptions/1]).
|
||||
|
||||
-define(TRIAL_DAYS, 30).
|
||||
|
||||
@@ -291,8 +293,8 @@ parse_iso_datetime(Other) -> Other.
|
||||
%%% @doc Количество подписок.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_subscription() -> non_neg_integer().
|
||||
count_subscription() ->
|
||||
-spec count_subscriptions() -> non_neg_integer().
|
||||
count_subscriptions() ->
|
||||
mnesia:table_info(subscription, size).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -315,3 +317,55 @@ count_subscriptions_by_date(From, To) ->
|
||||
lists:sort(Counts).
|
||||
|
||||
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).
|
||||
@@ -5,7 +5,7 @@
|
||||
-export([count_tickets/0]).
|
||||
-export([create_ticket/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([delete_ticket/1]).
|
||||
-export([count_tickets_by_date/2]).
|
||||
@@ -236,6 +236,23 @@ avg_resolution_time() ->
|
||||
Total / length(Closed) / 3600.0
|
||||
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 Количество тикетов, назначенных на администратора.
|
||||
%%% @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) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end,
|
||||
Ticket, Updates),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
-export([list_users/0]).
|
||||
-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_by_role/0, count_users_by_status/0]).
|
||||
-export([create_bot/2, delete_bot/1]).
|
||||
|
||||
%% ─────────────────────────────────────────────────────────────────
|
||||
@@ -306,6 +307,38 @@ count_pending_users_by_date(From, To) ->
|
||||
end, [], Filtered),
|
||||
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}`.
|
||||
%%% @end
|
||||
|
||||
@@ -113,19 +113,24 @@ start_admin_http() ->
|
||||
{"/v1/admin/refresh", admin_handler_refresh, []},
|
||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||
{"/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/verification-token", admin_handler_user_verification_token, []},
|
||||
% ================== КАЛЕНДАРИ ==================
|
||||
{"/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/events", admin_handler_events, []},
|
||||
{"/v1/admin/events/stats", admin_handler_event_stats, []},
|
||||
{"/v1/admin/events/:id", admin_handler_event_by_id, []},
|
||||
% ================== ОТЧЁТЫ ==================
|
||||
{"/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/reviews", admin_handler_reviews, []},
|
||||
{"/v1/admin/reviews/stats", admin_handler_reviews_stats, []},
|
||||
{"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []},
|
||||
% ================== БАН-СЛОВА ==================
|
||||
{"/v1/admin/banned-words/batch", admin_handler_banned_words, []},
|
||||
@@ -137,6 +142,7 @@ start_admin_http() ->
|
||||
{"/v1/admin/tickets", admin_handler_tickets, []},
|
||||
% ================== ПОДПИСКИ ==================
|
||||
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
||||
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
{"/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
|
||||
}.
|
||||
@@ -47,15 +47,18 @@ stats_schema() ->
|
||||
type => object,
|
||||
properties => #{
|
||||
<<"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">>},
|
||||
<<"reviews_total">> => #{type => integer, description => <<"Total number of reviews">>},
|
||||
<<"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_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">>},
|
||||
<<"trial_subscriptions">> => #{type => integer, description => <<"Number of trial subscriptions">>},
|
||||
<<"avg_ticket_resolution_h">> => #{type => number, format => float, description => <<"Average ticket resolution time in hours">>},
|
||||
<<"registrations_by_day">> => #{
|
||||
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 Административный обработчик для получения статистики по тикетам.
|
||||
%%% GET – возвращает агрегированную статистику тикетов
|
||||
%%% (количество по статусам: open, in_progress, resolved, closed).
|
||||
%%% @doc Административный обработчик статистики по тикетам.
|
||||
%%% GET – возвращает агрегированные данные по тикетам.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_ticket_stats).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
@@ -16,8 +14,8 @@
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_stats(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> get_ticket_stats(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%%% Swagger metadata
|
||||
@@ -25,38 +23,41 @@ init(Req, _Opts) ->
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/tickets/stats">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get ticket statistics (admin)">>,
|
||||
tags => [<<"Tickets">>],
|
||||
responses => #{
|
||||
path => <<"/v1/admin/tickets/stats">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get ticket statistics">>,
|
||||
tags => [<<"Tickets">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Ticket statistics">>,
|
||||
content => #{<<"application/json">> => #{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">>}
|
||||
content => #{<<"application/json">> => #{schema => ticket_stats_schema()}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
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
|
||||
|
||||
%% @doc Получить статистику тикетов. Доступно только администраторам.
|
||||
-spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_stats(Req) ->
|
||||
%% @doc GET /v1/admin/tickets/stats – получить статистику по тикетам.
|
||||
-spec get_ticket_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_ticket_stats(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Stats = core_ticket:stats(),
|
||||
{ok, AdminId, Req1} ->
|
||||
Stats = logic_ticket:get_statistics(AdminId),
|
||||
handler_utils:send_json(Req1, 200, Stats);
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
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.
|
||||
@@ -1,10 +1,3 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Модуль сбора статистики для административной панели.
|
||||
%%%
|
||||
%%% Предоставляет функции `get_stats/2` и `get_stats/4`, возвращающие
|
||||
%%% агрегированные показатели в зависимости от роли администратора.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_stats).
|
||||
-export([get_stats/2, get_stats/4]).
|
||||
-include("records.hrl").
|
||||
@@ -53,9 +46,14 @@ build_superadmin_stats(From, To) ->
|
||||
events_total => core_event:count_events(),
|
||||
calendars_total => core_calendar:count_calendars(),
|
||||
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_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()),
|
||||
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)),
|
||||
@@ -80,9 +78,14 @@ build_admin_stats(From, To) ->
|
||||
events_total => core_event:count_events(),
|
||||
calendars_total => core_calendar:count_calendars(),
|
||||
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_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()),
|
||||
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)),
|
||||
|
||||
@@ -12,22 +12,27 @@ admin() ->
|
||||
admin_handler_health,
|
||||
admin_handler_stats,
|
||||
admin_handler_login,
|
||||
admin_handler_refresh, % ← добавлен
|
||||
admin_handler_refresh,
|
||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||
admin_handler_users,
|
||||
admin_handler_user_by_id,
|
||||
admin_handler_user_verification_token, % ← добавлен
|
||||
admin_handler_user_verification_token,
|
||||
admin_handler_user_stats,
|
||||
% ================== КАЛЕНДАРИ ==================
|
||||
admin_handler_calendars, % ← добавлен
|
||||
admin_handler_calendar_by_id, % ← добавлен
|
||||
admin_handler_calendars,
|
||||
admin_handler_calendar_stats,
|
||||
admin_handler_calendar_by_id,
|
||||
% ================== СОБЫТИЯ ==================
|
||||
admin_handler_events,
|
||||
admin_handler_event_stats,
|
||||
admin_handler_event_by_id,
|
||||
% ================== ОТЧЁТЫ ==================
|
||||
admin_handler_reports,
|
||||
admin_handler_report_stats,
|
||||
admin_handler_report_by_id,
|
||||
% ================== ОТЗЫВЫ ==================
|
||||
admin_handler_reviews,
|
||||
admin_handler_reviews_stats,
|
||||
admin_handler_reviews_by_id,
|
||||
% ================== БАН-СЛОВА ==================
|
||||
admin_handler_banned_words,
|
||||
@@ -37,13 +42,14 @@ admin() ->
|
||||
admin_handler_tickets,
|
||||
% ================== ПОДПИСКИ ==================
|
||||
admin_handler_subscriptions,
|
||||
admin_handler_subscription_stats,
|
||||
admin_handler_subscriptions_by_id,
|
||||
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
||||
admin_handler_moderation,
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
admin_handler_me,
|
||||
admin_handler_admins,
|
||||
admin_handler_admins_by_id, % ← добавлен
|
||||
admin_handler_admins_by_id,
|
||||
admin_handler_audit
|
||||
],
|
||||
lists:flatmap(fun trails_from_module/1, Modules).
|
||||
@@ -59,7 +65,7 @@ user() ->
|
||||
handler_register,
|
||||
handler_login,
|
||||
handler_refresh,
|
||||
handler_verify, % ← добавлен
|
||||
handler_verify,
|
||||
handler_booking_by_id,
|
||||
handler_bookings,
|
||||
handler_calendar_by_id,
|
||||
|
||||
@@ -3,13 +3,24 @@
|
||||
%%%
|
||||
%%% Покрывает эндпоинты:
|
||||
%%% 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 – наличие ключевых метрик, включая
|
||||
%%% pending_users_total, *_by_day
|
||||
%%% - получение общей статистики для всех ролей администраторов
|
||||
%%% - для superadmin и admin – наличие ключевых метрик, включая pending_users_total, *_by_day
|
||||
%%% - для moderator и support – ответ непустой
|
||||
%%% - работу с фильтром по датам (from, to)
|
||||
%%% - статистику по пользователям (users/stats)
|
||||
%%% - статистику по событиям (events/stats)
|
||||
%%% - статистику по жалобам (reports/stats)
|
||||
%%% - статистику по отзывам (reviews/stats)
|
||||
%%% - статистику по подпискам (subscriptions/stats)
|
||||
%%% - статистику по календарям (calendars/stats)
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_stats_tests).
|
||||
@@ -27,6 +38,31 @@ test() ->
|
||||
ModerToken = api_test_runner:get_moderator_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("Admin", AdminToken, strict),
|
||||
test_stats_for_role("Moderator", ModerToken, loose),
|
||||
@@ -34,6 +70,14 @@ test() ->
|
||||
|
||||
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 ==="),
|
||||
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.
|
||||
test_stats_for_role(RoleName, Token, Strictness) ->
|
||||
ct:pal(" TEST: Get stats for role ~s", [RoleName]),
|
||||
@@ -51,18 +92,32 @@ test_stats_for_role(RoleName, Token, Strictness) ->
|
||||
?assert(is_map(Stats)),
|
||||
case Strictness of
|
||||
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(HasUsers orelse HasEvents),
|
||||
% Проверяем наличие новых ключей
|
||||
% Тотальные значения
|
||||
?assert(maps:is_key(<<"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(<<"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(<<"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(<<"calendars_by_day">>, Stats))),
|
||||
?assert(is_list(maps:get(<<"reports_by_day">>, Stats))),
|
||||
@@ -74,7 +129,6 @@ test_stats_for_role(RoleName, Token, Strictness) ->
|
||||
end,
|
||||
ct:pal(" OK: ~p keys", [length(maps:keys(Stats))]).
|
||||
|
||||
%% @doc GET /v1/admin/stats?from=...&to=... – проверяет фильтрацию по датам.
|
||||
-spec test_stats_with_dates(binary()) -> ok.
|
||||
test_stats_with_dates(Token) ->
|
||||
ct:pal(" TEST: Get stats with date range"),
|
||||
@@ -85,3 +139,84 @@ test_stats_with_dates(Token) ->
|
||||
?assert(is_map(Stats)),
|
||||
?assert(maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats)),
|
||||
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)]).
|
||||
@@ -14,9 +14,6 @@
|
||||
-module(user_verification_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-export([test/0]).
|
||||
-export([verification_test/0]).
|
||||
|
||||
verification_test() -> test().
|
||||
|
||||
-spec test() -> ok.
|
||||
test() ->
|
||||
|
||||
Reference in New Issue
Block a user