diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index 55b7cfa..382dd69 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -3,6 +3,7 @@ -export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). -export([count_calendars/0, list_all/0]). -export([freeze/2, unfreeze/2]). +-export([count_calendars_by_date/2]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -152,6 +153,27 @@ freeze(Id, Reason) -> unfreeze(Id, Reason) -> update(Id, [{status, active}, {reason, Reason}]). +%%%------------------------------------------------------------------- +%%% @doc Подсчёт календарей, созданных в заданном временном диапазоне. +%%% @end +%%%------------------------------------------------------------------- +-spec count_calendars_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> + [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. +count_calendars_by_date(From, To) -> + All = mnesia:dirty_match_object(#calendar{_ = '_'}), + Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso + C#calendar.created_at =< To end, All), + Counts = lists:foldl(fun(C, Acc) -> + Day = date_part(C#calendar.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1}) + end + end, [], Filtered), + lists:sort(Counts). + +date_part({{Y,M,D}, _}) -> {Y,M,D}. + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== diff --git a/src/core/core_report.erl b/src/core/core_report.erl index a9b407c..48933ee 100644 --- a/src/core/core_report.erl +++ b/src/core/core_report.erl @@ -3,6 +3,7 @@ -export([create/4, get_by_id/1, list_all/0, list_by_reporter/1, update_status/3, count_reports/0]). -export([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]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -156,4 +157,25 @@ avg_resolution_time(Status) -> calendar:datetime_to_gregorian_seconds(R#report.created_at) || R <- Reports, R#report.resolved_at /= undefined]), Total / length(Reports) / 3600.0 - end. \ No newline at end of file + end. + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт жалоб, созданных в заданном временном диапазоне. +%%% @end +%%%------------------------------------------------------------------- +-spec count_reports_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> + [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. +count_reports_by_date(From, To) -> + All = mnesia:dirty_match_object(#report{_ = '_'}), + Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso + R#report.created_at =< To end, All), + Counts = lists:foldl(fun(R, Acc) -> + Day = date_part(R#report.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts). + +date_part({{Y,M,D}, _}) -> {Y,M,D}. \ No newline at end of file diff --git a/src/core/core_review.erl b/src/core/core_review.erl index 7a8312b..929c871 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -4,6 +4,7 @@ hide/2, unhide/2]). -export([get_average_rating/2, has_user_reviewed/3]). -export([count_reviews/0, list_all/0]). +-export([count_reviews_by_date/2]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -182,6 +183,27 @@ count_reviews() -> list_all() -> mnesia:dirty_match_object(#review{_ = '_'}). +%%%------------------------------------------------------------------- +%%% @doc Подсчёт отзывов, созданных в заданном временном диапазоне. +%%% @end +%%%------------------------------------------------------------------- +-spec count_reviews_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> + [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. +count_reviews_by_date(From, To) -> + All = mnesia:dirty_match_object(#review{_ = '_'}), + Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso + R#review.created_at =< To end, All), + Counts = lists:foldl(fun(R, Acc) -> + Day = date_part(R#review.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts). + +date_part({{Y,M,D}, _}) -> {Y,M,D}. + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl index 615219d..c44ae20 100644 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -4,6 +4,7 @@ -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_by_date/2]). -define(TRIAL_DAYS, 30). @@ -292,4 +293,25 @@ parse_iso_datetime(Other) -> Other. %%%------------------------------------------------------------------- -spec count_subscription() -> non_neg_integer(). count_subscription() -> - mnesia:table_info(subscription, size). \ No newline at end of file + mnesia:table_info(subscription, size). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт подписок, созданных в заданном временном диапазоне. +%%% @end +%%%------------------------------------------------------------------- +-spec count_subscriptions_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> + [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. +count_subscriptions_by_date(From, To) -> + All = mnesia:dirty_match_object(#subscription{_ = '_'}), + Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso + S#subscription.created_at =< To end, All), + Counts = lists:foldl(fun(S, Acc) -> + Day = date_part(S#subscription.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts). + +date_part({{Y,M,D}, _}) -> {Y,M,D}. \ No newline at end of file diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index 27e7bba..170d8d5 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -7,7 +7,8 @@ -export([list_by_user/1]). -export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2]). -export([update_ticket/2]). --export([delete_ticket/1]). % ← для admin_handler_ticket_by_id +-export([delete_ticket/1]). +-export([count_tickets_by_date/2]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -248,6 +249,27 @@ count_tickets_by_admin(AdminId, Status) -> Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'}, length(mnesia:dirty_match_object(Match)). +%%%------------------------------------------------------------------- +%%% @doc Подсчёт тикетов, созданных в заданном временном диапазоне. +%%% @end +%%%------------------------------------------------------------------- +-spec count_tickets_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> + [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. +count_tickets_by_date(From, To) -> + All = mnesia:dirty_match_object(#ticket{_ = '_'}), + Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso + T#ticket.first_seen =< To end, All), + Counts = lists:foldl(fun(T, Acc) -> + Day = date_part(T#ticket.first_seen), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts). + +date_part({{Y,M,D}, _}) -> {Y,M,D}. + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== diff --git a/src/core/core_user.erl b/src/core/core_user.erl index d9c888d..6c445ed 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -5,7 +5,7 @@ -export([email_exists/1]). -export([list_users/0]). -export([block/2, unblock/2]). --export([count_users/0, count_users_by_date/2, list_all/0]). +-export([count_users/0, count_users_by_date/2, count_pending_users/0, count_pending_users_by_date/2, list_all/0]). -export([create_bot/2, delete_bot/1]). %% ───────────────────────────────────────────────────────────────── @@ -277,6 +277,35 @@ count_users_by_date(From, To) -> end, [], Filtered), lists:sort(Counts). +%%%------------------------------------------------------------------- +%%% @doc Количество неподтверждённых пользователей (status = pending). +%%% @end +%%%------------------------------------------------------------------- +-spec count_pending_users() -> non_neg_integer(). +count_pending_users() -> + Match = #user{status = pending, _ = '_'}, + length(mnesia:dirty_match_object(Match)). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням. +%%% @end +%%%------------------------------------------------------------------- +-spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> + [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. +count_pending_users_by_date(From, To) -> + All = mnesia:dirty_match_object(#user{_ = '_'}), + Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All), + Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso + U#user.created_at =< To end, Pending), + Counts = lists:foldl(fun(U, Acc) -> + Day = date_part(U#user.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts). + %%%------------------------------------------------------------------- %%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @end diff --git a/src/handlers/admin/admin_handler_stats.erl b/src/handlers/admin/admin_handler_stats.erl index ba2f1ac..15d423f 100644 --- a/src/handlers/admin/admin_handler_stats.erl +++ b/src/handlers/admin/admin_handler_stats.erl @@ -35,10 +35,7 @@ trails() -> responses => #{ 200 => #{ description => <<"Statistics object">>, - content => #{<<"application/json">> => #{schema => #{ - type => object, - properties => stats_schema() - }}} + content => #{<<"application/json">> => #{schema => stats_schema()}} }, 403 => #{description => <<"Admin access required">>} } @@ -47,14 +44,65 @@ trails() -> stats_schema() -> #{ - <<"users">> => #{type => integer, description => <<"Total number of users">>}, - <<"events">> => #{type => integer}, - <<"reviews">> => #{type => integer}, - <<"calendars">> => #{type => integer}, - <<"reports">> => #{type => integer}, - <<"tickets">> => #{type => integer}, - <<"subscriptions">> => #{type => integer}, - <<"active_subscriptions">> => #{type => integer} + type => object, + properties => #{ + <<"users_total">> => #{type => integer, description => <<"Total number of users">>}, + <<"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">>}, + <<"tickets_total">> => #{type => integer, description => <<"Total number of tickets">>}, + <<"tickets_open">> => #{type => integer, description => <<"Number of open tickets">>}, + <<"subscriptions_total">> => #{type => integer, description => <<"Total number of subscriptions">>}, + <<"active_subscriptions">> => #{type => integer, description => <<"Number of active subscriptions">>}, + <<"pending_users_total">> => #{type => integer, description => <<"Number of users awaiting email verification">>}, + <<"avg_ticket_resolution_h">> => #{type => number, format => float, description => <<"Average ticket resolution time in hours">>}, + <<"registrations_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily user registrations">> + }, + <<"events_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily event creations">> + }, + <<"reviews_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily review creations">> + }, + <<"calendars_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily calendar creations">> + }, + <<"reports_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily report creations">> + }, + <<"tickets_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily ticket creations">> + }, + <<"subscriptions_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily subscription creations">> + }, + <<"pending_users_by_day">> => #{ + type => array, + items => #{type => object, properties => #{<<"date">> => #{type => string}, <<"count">> => #{type => integer}}}, + description => <<"Daily pending user registrations">> + }, + <<"admin_activity">> => #{ + type => array, + items => #{type => object}, + description => <<"Recent admin actions (superadmin only)">> + } + } }. %%% Internal functions diff --git a/src/logic/logic_stats.erl b/src/logic/logic_stats.erl index 8e06ddc..6623b73 100644 --- a/src/logic/logic_stats.erl +++ b/src/logic/logic_stats.erl @@ -49,6 +49,7 @@ get_stats(_, _, _, _) -> build_superadmin_stats(From, To) -> #{ users_total => core_user:count_users(), + pending_users_total => core_user:count_pending_users(), events_total => core_event:count_events(), calendars_total => core_calendar:count_calendars(), reviews_total => core_review:count_reviews(), @@ -58,6 +59,12 @@ build_superadmin_stats(From, To) -> avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), 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)), + reviews_by_day => date_list_to_json(core_review:count_reviews_by_date(From, To)), + calendars_by_day => date_list_to_json(core_calendar:count_calendars_by_date(From, To)), + reports_by_day => date_list_to_json(core_report:count_reports_by_date(From, To)), + tickets_by_day => date_list_to_json(core_ticket:count_tickets_by_date(From, To)), + subscriptions_by_day => date_list_to_json(core_subscription:count_subscriptions_by_date(From, To)), + pending_users_by_day => date_list_to_json(core_user:count_pending_users_by_date(From, To)), admin_activity => collect_admin_activity() }. @@ -69,6 +76,7 @@ build_superadmin_stats(From, To) -> build_admin_stats(From, To) -> #{ users_total => core_user:count_users(), + pending_users_total => core_user:count_pending_users(), events_total => core_event:count_events(), calendars_total => core_calendar:count_calendars(), reviews_total => core_review:count_reviews(), @@ -77,7 +85,13 @@ build_admin_stats(From, To) -> tickets_total => length(core_ticket:list_all()), 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)) + events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)), + reviews_by_day => date_list_to_json(core_review:count_reviews_by_date(From, To)), + calendars_by_day => date_list_to_json(core_calendar:count_calendars_by_date(From, To)), + reports_by_day => date_list_to_json(core_report:count_reports_by_date(From, To)), + tickets_by_day => date_list_to_json(core_ticket:count_tickets_by_date(From, To)), + subscriptions_by_day => date_list_to_json(core_subscription:count_subscriptions_by_date(From, To)), + pending_users_by_day => date_list_to_json(core_user:count_pending_users_by_date(From, To)) }. %%%------------------------------------------------------------------- diff --git a/test/api/admins/admin_stats_tests.erl b/test/api/admins/admin_stats_tests.erl index f596c9b..44e3aa5 100644 --- a/test/api/admins/admin_stats_tests.erl +++ b/test/api/admins/admin_stats_tests.erl @@ -2,28 +2,26 @@ %%% @doc Тесты административного API для получения статистики. %%% %%% Покрывает эндпоинты: -%%% GET /v1/admin/stats +%%% GET /v1/admin/stats %%% %%% Проверяет: -%%% - получение статистики для всех четырёх ролей администраторов -%%% - для superadmin и admin – наличие ключевых метрик -%%% - для moderator и support – ответ непустой -%%% - работу с фильтром по датам (from, to) +%%% - получение статистики для всех четырёх ролей администраторов +%%% - для superadmin и admin – наличие ключевых метрик, включая +%%% pending_users_total, *_by_day +%%% - для moderator и support – ответ непустой +%%% - работу с фильтром по датам (from, to) %%% @end %%%------------------------------------------------------------------- -module(admin_stats_tests). -include_lib("eunit/include/eunit.hrl"). - -export([test/0]). %%%=================================================================== %%% Главная тестовая функция %%%=================================================================== - -spec test() -> ok. test() -> ct:pal("=== Admin Stats Tests ==="), - SuperToken = api_test_runner:get_superadmin_token(), AdminToken = api_test_runner:get_admin_token(), ModerToken = api_test_runner:get_moderator_token(), @@ -44,34 +42,46 @@ test() -> %%%=================================================================== %% @doc Проверяет получение статистики для конкретной роли. -%% strict – ожидаем ключи users_total/users и events_total/events -%% loose – просто убеждаемся, что ответ непустой +%% 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]), + ct:pal(" TEST: Get stats for role ~s", [RoleName]), Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token), ?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); + 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(<<"pending_users_total">>, Stats)), + ?assert(maps:is_key(<<"reviews_by_day">>, Stats)), + ?assert(maps:is_key(<<"calendars_by_day">>, Stats)), + ?assert(maps:is_key(<<"reports_by_day">>, Stats)), + ?assert(maps:is_key(<<"tickets_by_day">>, Stats)), + ?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)), + ?assert(maps:is_key(<<"pending_users_by_day">>, Stats)), + % Проверяем, что *_by_day являются списками + ?assert(is_list(maps:get(<<"reviews_by_day">>, Stats))), + ?assert(is_list(maps:get(<<"calendars_by_day">>, Stats))), + ?assert(is_list(maps:get(<<"reports_by_day">>, Stats))), + ?assert(is_list(maps:get(<<"tickets_by_day">>, Stats))), + ?assert(is_list(maps:get(<<"subscriptions_by_day">>, Stats))), + ?assert(is_list(maps:get(<<"pending_users_by_day">>, Stats))); loose -> ?assert(map_size(Stats) > 0) 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. test_stats_with_dates(Token) -> - ct:pal(" TEST: Get stats with date range"), + ct:pal(" TEST: Get stats with date range"), From = <<"2026-01-01T00:00:00Z">>, - To = <<"2026-12-31T23:59:59Z">>, + To = <<"2026-12-31T23:59:59Z">>, Path = <<"/v1/admin/stats?from=", From/binary, "&to=", To/binary>>, Stats = api_test_runner:admin_get(Path, Token), ?assert(is_map(Stats)), - ?assert(maps:is_key(<<"users_total">>, Stats) orelse - maps:is_key(<<"users">>, Stats)), - ct:pal(" OK"). \ No newline at end of file + ?assert(maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats)), + ct:pal(" OK"). \ No newline at end of file